diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9922ceb12f5..f82dea2f230 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,50 @@ jobs: node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD + changed-extensions: + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + outputs: + has_changed_extensions: ${{ steps.changed.outputs.has_changed_extensions }} + changed_extensions_matrix: ${{ steps.changed.outputs.changed_extensions_matrix }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 1 + fetch-tags: false + submodules: false + + - name: Ensure changed-extensions base commit + uses: ./.github/actions/ensure-base-commit + with: + base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} + fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }} + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + install-deps: "false" + use-sticky-disk: "false" + + - name: Detect changed extensions + id: changed + env: + BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} + run: | + node --input-type=module <<'EOF' + import { appendFileSync } from "node:fs"; + import { listChangedExtensionIds } from "./scripts/test-extension.mjs"; + + const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, head: "HEAD" }); + const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) }); + + appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8"); + appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8"); + EOF + # Build dist once for Node-relevant changes and share it with downstream jobs. build-artifacts: needs: [docs-scope, changed-scope] @@ -205,6 +249,29 @@ jobs: if: matrix.runtime != 'bun' || github.event_name != 'pull_request' run: ${{ matrix.command }} + extension-fast: + name: "extension-fast (${{ matrix.extension }})" + needs: [docs-scope, changed-scope, changed-extensions] + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.changed-extensions.outputs.changed_extensions_matrix) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + use-sticky-disk: "false" + + - name: Run changed extension tests + run: pnpm test:extension ${{ matrix.extension }} + # Types, lint, and format check. check: name: "check" diff --git a/.npmignore b/.npmignore index 7cd53fdbc08..fcc490ae35d 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1,2 @@ **/node_modules/ +docs/.generated/ diff --git a/CHANGELOG.md b/CHANGELOG.md index df03ad8fc5d..5673d2dd5f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,49 +13,52 @@ Docs: https://docs.openclaw.ai - Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized. - Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy. - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. -- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. +- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. (#47630) Thanks @vincentkoc. - Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327. - Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl. -- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819) -- Plugins/marketplaces: add Claude marketplace registry resolution, `plugin@marketplace` installs, marketplace listing, and update support, plus Docker E2E coverage for local and official marketplace flows. Thanks @vincentkoc. -- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) -- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) +- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819) Thanks @Takhoffman. +- Plugins/marketplaces: add Claude marketplace registry resolution, `plugin@marketplace` installs, marketplace listing, and update support, plus Docker E2E coverage for local and official marketplace flows. (#48058) Thanks @vincentkoc. +- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) Thanks @Takhoffman. +- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) Thanks @day253. - Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl. - Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280. - Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility. - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. -- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. +- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF. +- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) Thanks @scoootscooob. - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. +- Browser/existing-session: support `browser.profiles..userDataDir` so Chrome DevTools MCP can attach to Brave, Edge, and other Chromium-based browsers through their own user data directories. (#48170) Thanks @velvet-shark. +- Skills/prompt budget: preserve all registered skills via a compact catalog fallback before dropping entries when the full prompt format exceeds `maxSkillsPromptChars`. (#47553) Thanks @snese. ### Breaking -- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. Thanks @vincentkoc. +- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc. ### Fixes - Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli. -- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. +- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman. - Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc. - Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts. -- Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. Fixes #46924 and #47041. -- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc. +- Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. (#47902) Fixes #46924 and #47041. Thanks @steipete. +- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. (#46800) Thanks @vincentkoc. - Gateway/restart: defer externally signaled unmanaged restarts through the in-process idle drain, and preserve the restored subagent run as remap fallback during orphan recovery so resumed sessions do not duplicate work. (#47719) Thanks @joeykrug. - Control UI/session routing: preserve established external delivery routes when webchat views or sends in externally originated sessions, so subagent completions still return to the original channel instead of the dashboard. (#47797) Thanks @brokemac79. -- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. +- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) Thanks @scoootscooob. - CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc. - CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc. - CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc. - CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc. - Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`) - Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc. -- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc. -- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc. -- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc. -- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc. +- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. (#46802) Thanks @vincentkoc. +- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. (#46816) Thanks @vincentkoc. +- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. (#46803) Thanks @vincentkoc. +- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. (#46799) Thanks @vincentkoc. - ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. (#46817) Thanks @zpbrent and @vincentkoc. - ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc. -- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc. +- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. (#46801) Thanks @vincentkoc. - macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc. - Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason. - Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026. @@ -63,35 +66,41 @@ Docs: https://docs.openclaw.ai - Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao. - Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc. - Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc. -- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc. -- Feishu/actions: expand the runtime action surface with message read/edit, explicit thread replies, pinning, and operator-facing chat/member inspection so Feishu can operate more of the workspace directly. +- Feishu/actions: expand the runtime action surface with message read/edit, explicit thread replies, pinning, and operator-facing chat/member inspection so Feishu can operate more of the workspace directly. (#47968) Thanks @Takhoffman. - Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw. -- Feishu/media: keep native image, file, audio, and video/media handling aligned across outbound sends, inbound downloads, thread replies, directory/action aliases, and capability docs so unsupported areas are explicit instead of implied. -- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT. +- Feishu/media: keep native image, file, audio, and video/media handling aligned across outbound sends, inbound downloads, thread replies, directory/action aliases, and capability docs so unsupported areas are explicit instead of implied. (#47968) Thanks @Takhoffman. +- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) Thanks @MonkeyLeeT. - WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason. - Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent. -- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274) -- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) +- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274) Thanks @obviyus. +- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) Thanks @obviyus. - Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28. -- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146) +- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#46663) Fixes #40146. Thanks @Takhoffman. - Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898. -- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. -- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. Thanks @vincentkoc. -- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras. +- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. (#46722) Thanks @Takhoffman. +- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. (#47413) Thanks @vincentkoc. +- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) Thanks @gumadeiras. - Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras. -- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. +- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) Thanks @luzhidong. - Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom. -- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc. +- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46580) Fixes #46532. Thanks @vincentkoc. - Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults. - Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles. -- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0. +- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#46596) Fixes #45777. Thanks @odysseus0. - CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. - Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark. - Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman. - Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc. - Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc. -- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411) +- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46515) Fixes #46411. Thanks @ademczuk. - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. +- Slack/startup: harden `@slack/bolt` import interop across current bundled runtime shapes so Slack monitors no longer crash with `App is not a constructor` after plugin-sdk bundling changes. (#45953) Thanks @merc1305. +- Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI. +- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup. (#47601) Thanks @ngutman. +- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement for Control UI operator sessions when `gateway.auth.mode=none`, so reverse-proxied dashboards no longer get stuck on `pairing required` despite auth being explicitly disabled. (#47148) Thanks @ademczuk. +- Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham. +- Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw. +- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native OpenAI-completions providers so compatible backends report token usage and cost again instead of showing all zeros. (#46500) Fixes #46142. Thanks @ademczuk. ## 2026.3.13 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4184a550691..9b1fa35d6a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,6 +89,9 @@ Welcome to the lobster tank! 🦞 - Test locally with your OpenClaw instance - Run tests: `pnpm build && pnpm check && pnpm test` +- For extension/plugin changes, run the fast local lane first: + - `pnpm test:extension ` + - If you changed shared plugin or channel surfaces, still run the broader relevant lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review - If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs. - Ensure CI checks pass - Keep PRs focused (one thing per PR; do not mix unrelated concerns) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt index 40cabebd17c..d9ad83175b4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt @@ -18,14 +18,13 @@ import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModels() private lateinit var permissionRequester: PermissionRequester + private var didAttachRuntimeUi = false + private var didStartNodeService = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) permissionRequester = PermissionRequester(this) - viewModel.camera.attachLifecycleOwner(this) - viewModel.camera.attachPermissionRequester(permissionRequester) - viewModel.sms.attachPermissionRequester(permissionRequester) lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -39,6 +38,20 @@ class MainActivity : ComponentActivity() { } } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.runtimeInitialized.collect { ready -> + if (!ready || didAttachRuntimeUi) return@collect + viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester) + didAttachRuntimeUi = true + if (!didStartNodeService) { + NodeForegroundService.start(this@MainActivity) + didStartNodeService = true + } + } + } + } + setContent { OpenClawTheme { Surface(modifier = Modifier) { @@ -46,9 +59,6 @@ class MainActivity : ComponentActivity() { } } } - - // Keep startup path lean: start foreground service after first frame. - window.decorView.post { NodeForegroundService.start(this) } } override fun onStart() { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 80f42e02843..82fe643314c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -2,209 +2,268 @@ package ai.openclaw.app import android.app.Application import androidx.lifecycle.AndroidViewModel -import ai.openclaw.app.gateway.GatewayEndpoint +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewModelScope +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatPendingToolCall +import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.OutgoingAttachment +import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.node.CameraCaptureManager import ai.openclaw.app.node.CanvasController import ai.openclaw.app.node.SmsManager import ai.openclaw.app.voice.VoiceConversationEntry +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +@OptIn(ExperimentalCoroutinesApi::class) class MainViewModel(app: Application) : AndroidViewModel(app) { - private val runtime: NodeRuntime = (app as NodeApp).runtime + private val nodeApp = app as NodeApp + private val prefs = nodeApp.prefs + private val runtimeRef = MutableStateFlow(null) + private var foreground = true - val canvas: CanvasController = runtime.canvas - val canvasCurrentUrl: StateFlow = runtime.canvas.currentUrl - val canvasA2uiHydrated: StateFlow = runtime.canvasA2uiHydrated - val canvasRehydratePending: StateFlow = runtime.canvasRehydratePending - val canvasRehydrateErrorText: StateFlow = runtime.canvasRehydrateErrorText - val camera: CameraCaptureManager = runtime.camera - val sms: SmsManager = runtime.sms + private fun ensureRuntime(): NodeRuntime { + runtimeRef.value?.let { return it } + val runtime = nodeApp.ensureRuntime() + runtime.setForeground(foreground) + runtimeRef.value = runtime + return runtime + } - val gateways: StateFlow> = runtime.gateways - val discoveryStatusText: StateFlow = runtime.discoveryStatusText + private fun runtimeState( + initial: T, + selector: (NodeRuntime) -> StateFlow, + ): StateFlow = + runtimeRef + .flatMapLatest { runtime -> runtime?.let(selector) ?: flowOf(initial) } + .stateIn(viewModelScope, SharingStarted.Eagerly, initial) - val isConnected: StateFlow = runtime.isConnected - val isNodeConnected: StateFlow = runtime.nodeConnected - val statusText: StateFlow = runtime.statusText - val serverName: StateFlow = runtime.serverName - val remoteAddress: StateFlow = runtime.remoteAddress - val pendingGatewayTrust: StateFlow = runtime.pendingGatewayTrust - val isForeground: StateFlow = runtime.isForeground - val seamColorArgb: StateFlow = runtime.seamColorArgb - val mainSessionKey: StateFlow = runtime.mainSessionKey + val runtimeInitialized: StateFlow = + runtimeRef + .flatMapLatest { runtime -> flowOf(runtime != null) } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) - val cameraHud: StateFlow = runtime.cameraHud - val cameraFlashToken: StateFlow = runtime.cameraFlashToken + val canvasCurrentUrl: StateFlow = runtimeState(initial = null) { it.canvas.currentUrl } + val canvasA2uiHydrated: StateFlow = runtimeState(initial = false) { it.canvasA2uiHydrated } + val canvasRehydratePending: StateFlow = runtimeState(initial = false) { it.canvasRehydratePending } + val canvasRehydrateErrorText: StateFlow = runtimeState(initial = null) { it.canvasRehydrateErrorText } - val instanceId: StateFlow = runtime.instanceId - val displayName: StateFlow = runtime.displayName - val cameraEnabled: StateFlow = runtime.cameraEnabled - val locationMode: StateFlow = runtime.locationMode - val locationPreciseEnabled: StateFlow = runtime.locationPreciseEnabled - val preventSleep: StateFlow = runtime.preventSleep - val micEnabled: StateFlow = runtime.micEnabled - val micCooldown: StateFlow = runtime.micCooldown - val micStatusText: StateFlow = runtime.micStatusText - val micLiveTranscript: StateFlow = runtime.micLiveTranscript - val micIsListening: StateFlow = runtime.micIsListening - val micQueuedMessages: StateFlow> = runtime.micQueuedMessages - val micConversation: StateFlow> = runtime.micConversation - val micInputLevel: StateFlow = runtime.micInputLevel - val micIsSending: StateFlow = runtime.micIsSending - val speakerEnabled: StateFlow = runtime.speakerEnabled - val manualEnabled: StateFlow = runtime.manualEnabled - val manualHost: StateFlow = runtime.manualHost - val manualPort: StateFlow = runtime.manualPort - val manualTls: StateFlow = runtime.manualTls - val gatewayToken: StateFlow = runtime.gatewayToken - val onboardingCompleted: StateFlow = runtime.onboardingCompleted - val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled + val gateways: StateFlow> = runtimeState(initial = emptyList()) { it.gateways } + val discoveryStatusText: StateFlow = runtimeState(initial = "Searching…") { it.discoveryStatusText } - val chatSessionKey: StateFlow = runtime.chatSessionKey - val chatSessionId: StateFlow = runtime.chatSessionId - val chatMessages = runtime.chatMessages - val chatError: StateFlow = runtime.chatError - val chatHealthOk: StateFlow = runtime.chatHealthOk - val chatThinkingLevel: StateFlow = runtime.chatThinkingLevel - val chatStreamingAssistantText: StateFlow = runtime.chatStreamingAssistantText - val chatPendingToolCalls = runtime.chatPendingToolCalls - val chatSessions = runtime.chatSessions - val pendingRunCount: StateFlow = runtime.pendingRunCount + val isConnected: StateFlow = runtimeState(initial = false) { it.isConnected } + val isNodeConnected: StateFlow = runtimeState(initial = false) { it.nodeConnected } + val statusText: StateFlow = runtimeState(initial = "Offline") { it.statusText } + val serverName: StateFlow = runtimeState(initial = null) { it.serverName } + val remoteAddress: StateFlow = runtimeState(initial = null) { it.remoteAddress } + val pendingGatewayTrust: StateFlow = runtimeState(initial = null) { it.pendingGatewayTrust } + val seamColorArgb: StateFlow = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb } + val mainSessionKey: StateFlow = runtimeState(initial = "main") { it.mainSessionKey } + + val cameraHud: StateFlow = runtimeState(initial = null) { it.cameraHud } + val cameraFlashToken: StateFlow = runtimeState(initial = 0L) { it.cameraFlashToken } + + val instanceId: StateFlow = prefs.instanceId + val displayName: StateFlow = prefs.displayName + val cameraEnabled: StateFlow = prefs.cameraEnabled + val locationMode: StateFlow = prefs.locationMode + val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled + val preventSleep: StateFlow = prefs.preventSleep + val manualEnabled: StateFlow = prefs.manualEnabled + val manualHost: StateFlow = prefs.manualHost + val manualPort: StateFlow = prefs.manualPort + val manualTls: StateFlow = prefs.manualTls + val gatewayToken: StateFlow = prefs.gatewayToken + val onboardingCompleted: StateFlow = prefs.onboardingCompleted + val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled + val speakerEnabled: StateFlow = prefs.speakerEnabled + val micEnabled: StateFlow = prefs.talkEnabled + + val micCooldown: StateFlow = runtimeState(initial = false) { it.micCooldown } + val micStatusText: StateFlow = runtimeState(initial = "Mic off") { it.micStatusText } + val micLiveTranscript: StateFlow = runtimeState(initial = null) { it.micLiveTranscript } + val micIsListening: StateFlow = runtimeState(initial = false) { it.micIsListening } + val micQueuedMessages: StateFlow> = runtimeState(initial = emptyList()) { it.micQueuedMessages } + val micConversation: StateFlow> = runtimeState(initial = emptyList()) { it.micConversation } + val micInputLevel: StateFlow = runtimeState(initial = 0f) { it.micInputLevel } + val micIsSending: StateFlow = runtimeState(initial = false) { it.micIsSending } + + val chatSessionKey: StateFlow = runtimeState(initial = "main") { it.chatSessionKey } + val chatSessionId: StateFlow = runtimeState(initial = null) { it.chatSessionId } + val chatMessages: StateFlow> = runtimeState(initial = emptyList()) { it.chatMessages } + val chatError: StateFlow = runtimeState(initial = null) { it.chatError } + val chatHealthOk: StateFlow = runtimeState(initial = false) { it.chatHealthOk } + val chatThinkingLevel: StateFlow = runtimeState(initial = "off") { it.chatThinkingLevel } + val chatStreamingAssistantText: StateFlow = runtimeState(initial = null) { it.chatStreamingAssistantText } + val chatPendingToolCalls: StateFlow> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls } + val chatSessions: StateFlow> = runtimeState(initial = emptyList()) { it.chatSessions } + val pendingRunCount: StateFlow = runtimeState(initial = 0) { it.pendingRunCount } + + init { + if (prefs.onboardingCompleted.value) { + ensureRuntime() + } + } + + val canvas: CanvasController + get() = ensureRuntime().canvas + + val camera: CameraCaptureManager + get() = ensureRuntime().camera + + val sms: SmsManager + get() = ensureRuntime().sms + + fun attachRuntimeUi(owner: LifecycleOwner, permissionRequester: PermissionRequester) { + val runtime = runtimeRef.value ?: return + runtime.camera.attachLifecycleOwner(owner) + runtime.camera.attachPermissionRequester(permissionRequester) + runtime.sms.attachPermissionRequester(permissionRequester) + } fun setForeground(value: Boolean) { - runtime.setForeground(value) + foreground = value + runtimeRef.value?.setForeground(value) } fun setDisplayName(value: String) { - runtime.setDisplayName(value) + prefs.setDisplayName(value) } fun setCameraEnabled(value: Boolean) { - runtime.setCameraEnabled(value) + prefs.setCameraEnabled(value) } fun setLocationMode(mode: LocationMode) { - runtime.setLocationMode(mode) + prefs.setLocationMode(mode) } fun setLocationPreciseEnabled(value: Boolean) { - runtime.setLocationPreciseEnabled(value) + prefs.setLocationPreciseEnabled(value) } fun setPreventSleep(value: Boolean) { - runtime.setPreventSleep(value) + prefs.setPreventSleep(value) } fun setManualEnabled(value: Boolean) { - runtime.setManualEnabled(value) + prefs.setManualEnabled(value) } fun setManualHost(value: String) { - runtime.setManualHost(value) + prefs.setManualHost(value) } fun setManualPort(value: Int) { - runtime.setManualPort(value) + prefs.setManualPort(value) } fun setManualTls(value: Boolean) { - runtime.setManualTls(value) + prefs.setManualTls(value) } fun setGatewayToken(value: String) { - runtime.setGatewayToken(value) + prefs.setGatewayToken(value) } fun setGatewayBootstrapToken(value: String) { - runtime.setGatewayBootstrapToken(value) + prefs.setGatewayBootstrapToken(value) } fun setGatewayPassword(value: String) { - runtime.setGatewayPassword(value) + prefs.setGatewayPassword(value) } fun setOnboardingCompleted(value: Boolean) { - runtime.setOnboardingCompleted(value) + if (value) { + ensureRuntime() + } + prefs.setOnboardingCompleted(value) } fun setCanvasDebugStatusEnabled(value: Boolean) { - runtime.setCanvasDebugStatusEnabled(value) + prefs.setCanvasDebugStatusEnabled(value) } fun setVoiceScreenActive(active: Boolean) { - runtime.setVoiceScreenActive(active) + ensureRuntime().setVoiceScreenActive(active) } fun setMicEnabled(enabled: Boolean) { - runtime.setMicEnabled(enabled) + ensureRuntime().setMicEnabled(enabled) } fun setSpeakerEnabled(enabled: Boolean) { - runtime.setSpeakerEnabled(enabled) + ensureRuntime().setSpeakerEnabled(enabled) } fun refreshGatewayConnection() { - runtime.refreshGatewayConnection() + ensureRuntime().refreshGatewayConnection() } fun connect(endpoint: GatewayEndpoint) { - runtime.connect(endpoint) + ensureRuntime().connect(endpoint) } fun connectManual() { - runtime.connectManual() + ensureRuntime().connectManual() } fun disconnect() { - runtime.disconnect() + runtimeRef.value?.disconnect() } fun acceptGatewayTrustPrompt() { - runtime.acceptGatewayTrustPrompt() + runtimeRef.value?.acceptGatewayTrustPrompt() } fun declineGatewayTrustPrompt() { - runtime.declineGatewayTrustPrompt() + runtimeRef.value?.declineGatewayTrustPrompt() } fun handleCanvasA2UIActionFromWebView(payloadJson: String) { - runtime.handleCanvasA2UIActionFromWebView(payloadJson) + ensureRuntime().handleCanvasA2UIActionFromWebView(payloadJson) } fun requestCanvasRehydrate(source: String = "screen_tab") { - runtime.requestCanvasRehydrate(source = source, force = true) + ensureRuntime().requestCanvasRehydrate(source = source, force = true) } fun refreshHomeCanvasOverviewIfConnected() { - runtime.refreshHomeCanvasOverviewIfConnected() + ensureRuntime().refreshHomeCanvasOverviewIfConnected() } fun loadChat(sessionKey: String) { - runtime.loadChat(sessionKey) + ensureRuntime().loadChat(sessionKey) } fun refreshChat() { - runtime.refreshChat() + ensureRuntime().refreshChat() } fun refreshChatSessions(limit: Int? = null) { - runtime.refreshChatSessions(limit = limit) + ensureRuntime().refreshChatSessions(limit = limit) } fun setChatThinkingLevel(level: String) { - runtime.setChatThinkingLevel(level) + ensureRuntime().setChatThinkingLevel(level) } fun switchChatSession(sessionKey: String) { - runtime.switchChatSession(sessionKey) + ensureRuntime().switchChatSession(sessionKey) } fun abortChat() { - runtime.abortChat() + ensureRuntime().abortChat() } fun sendChat(message: String, thinking: String, attachments: List) { - runtime.sendChat(message = message, thinking = thinking, attachments = attachments) + ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt index 0d172a8abe7..adfd4b73907 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt @@ -4,7 +4,18 @@ import android.app.Application import android.os.StrictMode class NodeApp : Application() { - val runtime: NodeRuntime by lazy { NodeRuntime(this) } + val prefs: SecurePrefs by lazy { SecurePrefs(this) } + + @Volatile private var runtimeInstance: NodeRuntime? = null + + fun ensureRuntime(): NodeRuntime { + runtimeInstance?.let { return it } + return synchronized(this) { + runtimeInstance ?: NodeRuntime(this, prefs).also { runtimeInstance = it } + } + } + + fun peekRuntime(): NodeRuntime? = runtimeInstance override fun onCreate() { super.onCreate() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt index 5761567ebcc..4c7ccdd56e5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt @@ -28,7 +28,11 @@ class NodeForegroundService : Service() { val initial = buildNotification(title = "OpenClaw Node", text = "Starting…") startForegroundWithTypes(notification = initial) - val runtime = (application as NodeApp).runtime + val runtime = (application as NodeApp).peekRuntime() + if (runtime == null) { + stopSelf() + return + } notificationJob = scope.launch { combine( @@ -59,7 +63,7 @@ class NodeForegroundService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_STOP -> { - (application as NodeApp).runtime.disconnect() + (application as NodeApp).peekRuntime()?.disconnect() stopSelf() return START_NOT_STICKY } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index c2bce9a247a..9ee6198e15c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -43,11 +43,12 @@ import kotlinx.serialization.json.buildJsonObject import java.util.UUID import java.util.concurrent.atomic.AtomicLong -class NodeRuntime(context: Context) { +class NodeRuntime( + context: Context, + val prefs: SecurePrefs = SecurePrefs(context.applicationContext), +) { private val appContext = context.applicationContext private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - val prefs = SecurePrefs(appContext) private val deviceAuthStore = DeviceAuthStore(prefs) val canvas = CanvasController() val camera = CameraCaptureManager(appContext) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index be430480fb0..37bb3f472ee 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -265,7 +265,7 @@ class ChatController( } val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") - val history = parseHistory(historyJson, sessionKey = key) + val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value) _messages.value = history.messages _sessionId.value = history.sessionId history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } @@ -336,7 +336,7 @@ class ChatController( try { val historyJson = session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""") - val history = parseHistory(historyJson, sessionKey = _sessionKey.value) + val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value) _messages.value = history.messages _sessionId.value = history.sessionId history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } @@ -450,7 +450,11 @@ class ChatController( } } - private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory { + private fun parseHistory( + historyJson: String, + sessionKey: String, + previousMessages: List, + ): ChatHistory { val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList()) val sid = root["sessionId"].asStringOrNull() val thinkingLevel = root["thinkingLevel"].asStringOrNull() @@ -470,7 +474,12 @@ class ChatController( ) } - return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages) + return ChatHistory( + sessionKey = sessionKey, + sessionId = sid, + thinkingLevel = thinkingLevel, + messages = reconcileMessageIds(previous = previousMessages, incoming = messages), + ) } private fun parseMessageContent(el: JsonElement): ChatMessageContent? { @@ -519,6 +528,47 @@ class ChatController( } } +internal fun reconcileMessageIds(previous: List, incoming: List): List { + if (previous.isEmpty() || incoming.isEmpty()) return incoming + + val idsByKey = LinkedHashMap>() + for (message in previous) { + val key = messageIdentityKey(message) ?: continue + idsByKey.getOrPut(key) { ArrayDeque() }.addLast(message.id) + } + + return incoming.map { message -> + val key = messageIdentityKey(message) ?: return@map message + val ids = idsByKey[key] ?: return@map message + val reusedId = ids.removeFirstOrNull() ?: return@map message + if (ids.isEmpty()) { + idsByKey.remove(key) + } + if (reusedId == message.id) return@map message + message.copy(id = reusedId) + } +} + +internal fun messageIdentityKey(message: ChatMessage): String? { + val role = message.role.trim().lowercase() + if (role.isEmpty()) return null + + val timestamp = message.timestampMs?.toString().orEmpty() + val contentFingerprint = + message.content.joinToString(separator = "\u001E") { part -> + listOf( + part.type.trim().lowercase(), + part.text?.trim().orEmpty(), + part.mimeType?.trim()?.lowercase().orEmpty(), + part.fileName?.trim().orEmpty(), + part.base64?.hashCode()?.toString().orEmpty(), + ).joinToString(separator = "\u001F") + } + + if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null + return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|") +} + private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt index b2b540bdb7a..8180d24bbed 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt @@ -1,7 +1,5 @@ package ai.openclaw.app.ui.chat -import android.graphics.BitmapFactory -import android.util.Base64 import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -28,8 +26,7 @@ internal fun rememberBase64ImageState(base64: String): Base64ImageState { image = withContext(Dispatchers.Default) { try { - val bytes = Base64.decode(base64, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null + val bitmap = decodeBase64Bitmap(base64) ?: return@withContext null bitmap.asImageBitmap() } catch (_: Throwable) { null diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt new file mode 100644 index 00000000000..6574fa8678d --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt @@ -0,0 +1,150 @@ +package ai.openclaw.app.ui.chat + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Base64 +import android.util.LruCache +import androidx.core.graphics.scale +import ai.openclaw.app.node.JpegSizeLimiter +import java.io.ByteArrayOutputStream +import kotlin.math.max +import kotlin.math.roundToInt + +private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600 +private const val CHAT_ATTACHMENT_MAX_BASE64_CHARS = 300 * 1024 +private const val CHAT_ATTACHMENT_START_QUALITY = 85 +private const val CHAT_DECODE_MAX_DIMENSION = 1600 +private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024 + +private val decodedBitmapCache = + object : LruCache(CHAT_IMAGE_CACHE_BYTES) { + override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount.coerceAtLeast(1) + } + +internal fun loadSizedImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment { + val fileName = normalizeAttachmentFileName((uri.lastPathSegment ?: "image").substringAfterLast('/')) + val bitmap = decodeScaledBitmap(resolver, uri, maxDimension = CHAT_ATTACHMENT_MAX_WIDTH) + if (bitmap == null) { + throw IllegalStateException("unsupported attachment") + } + val maxBytes = (CHAT_ATTACHMENT_MAX_BASE64_CHARS / 4) * 3 + val encoded = + JpegSizeLimiter.compressToLimit( + initialWidth = bitmap.width, + initialHeight = bitmap.height, + startQuality = CHAT_ATTACHMENT_START_QUALITY, + maxBytes = maxBytes, + minSize = 240, + encode = { width, height, quality -> + val working = + if (width == bitmap.width && height == bitmap.height) { + bitmap + } else { + bitmap.scale(width, height, true) + } + try { + val out = ByteArrayOutputStream() + if (!working.compress(Bitmap.CompressFormat.JPEG, quality, out)) { + throw IllegalStateException("attachment encode failed") + } + out.toByteArray() + } finally { + if (working !== bitmap) { + working.recycle() + } + } + }, + ) + val base64 = Base64.encodeToString(encoded.bytes, Base64.NO_WRAP) + return PendingImageAttachment( + id = uri.toString() + "#" + System.currentTimeMillis().toString(), + fileName = fileName, + mimeType = "image/jpeg", + base64 = base64, + ) +} + +internal fun decodeBase64Bitmap(base64: String, maxDimension: Int = CHAT_DECODE_MAX_DIMENSION): Bitmap? { + val cacheKey = "$maxDimension:${base64.length}:${base64.hashCode()}" + decodedBitmapCache.get(cacheKey)?.let { return it } + + val bytes = Base64.decode(base64, Base64.DEFAULT) + if (bytes.isEmpty()) return null + + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + val bitmap = + BitmapFactory.decodeByteArray( + bytes, + 0, + bytes.size, + BitmapFactory.Options().apply { + inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension) + inPreferredConfig = Bitmap.Config.RGB_565 + }, + ) ?: return null + + decodedBitmapCache.put(cacheKey, bitmap) + return bitmap +} + +internal fun computeInSampleSize(width: Int, height: Int, maxDimension: Int): Int { + if (width <= 0 || height <= 0 || maxDimension <= 0) return 1 + + var sample = 1 + var longestEdge = max(width, height) + while (longestEdge > maxDimension && sample < 64) { + sample *= 2 + longestEdge = max(width / sample, height / sample) + } + return sample.coerceAtLeast(1) +} + +internal fun normalizeAttachmentFileName(raw: String): String { + val trimmed = raw.trim() + if (trimmed.isEmpty()) return "image.jpg" + val stem = trimmed.substringBeforeLast('.', missingDelimiterValue = trimmed).ifEmpty { "image" } + return "$stem.jpg" +} + +private fun decodeScaledBitmap( + resolver: ContentResolver, + uri: Uri, + maxDimension: Int, +): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + resolver.openInputStream(uri).use { input -> + if (input == null) return null + BitmapFactory.decodeStream(input, null, bounds) + } + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + val decoded = + resolver.openInputStream(uri).use { input -> + if (input == null) return null + BitmapFactory.decodeStream( + input, + null, + BitmapFactory.Options().apply { + inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension) + inPreferredConfig = Bitmap.Config.ARGB_8888 + }, + ) + } ?: return null + + val longestEdge = max(decoded.width, decoded.height) + if (longestEdge <= maxDimension) return decoded + + val scale = maxDimension.toDouble() / longestEdge.toDouble() + val targetWidth = max(1, (decoded.width * scale).roundToInt()) + val targetHeight = max(1, (decoded.height * scale).roundToInt()) + val scaled = decoded.scale(targetWidth, targetHeight, true) + if (scaled !== decoded) { + decoded.recycle() + } + return scaled +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt index 976972a7831..96d5e7cf7f6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt @@ -6,12 +6,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -34,11 +36,19 @@ fun ChatMessageListCard( modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() + val displayMessages = remember(messages) { messages.asReversed() } + val stream = streamingAssistantText?.trim() - // With reverseLayout the newest item is at index 0 (bottom of screen). - LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) { + // New list items/tool rows should animate into view, but token streaming should not restart + // that animation on every delta. + LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) { listState.animateScrollToItem(index = 0) } + LaunchedEffect(stream) { + if (!stream.isNullOrEmpty()) { + listState.scrollToItem(index = 0) + } + } Box(modifier = modifier.fillMaxWidth()) { LazyColumn( @@ -50,8 +60,6 @@ fun ChatMessageListCard( ) { // With reverseLayout = true, index 0 renders at the BOTTOM. // So we emit newest items first: streaming → tools → typing → messages (newest→oldest). - - val stream = streamingAssistantText?.trim() if (!stream.isNullOrEmpty()) { item(key = "stream") { ChatStreamingAssistantBubble(text = stream) @@ -70,8 +78,8 @@ fun ChatMessageListCard( } } - items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx -> - ChatMessageBubble(message = messages[messages.size - 1 - idx]) + items(items = displayMessages, key = { it.id }) { message -> + ChatMessageBubble(message = message) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index a4a93eeceec..2d8fb255baa 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -1,8 +1,5 @@ package ai.openclaw.app.ui.chat -import android.content.ContentResolver -import android.net.Uri -import android.util.Base64 import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke @@ -47,7 +44,6 @@ import ai.openclaw.app.ui.mobileDanger import ai.openclaw.app.ui.mobileDangerSoft import ai.openclaw.app.ui.mobileText import ai.openclaw.app.ui.mobileTextSecondary -import java.io.ByteArrayOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -83,7 +79,7 @@ fun ChatSheetContent(viewModel: MainViewModel) { val next = uris.take(8).mapNotNull { uri -> try { - loadImageAttachment(resolver, uri) + loadSizedImageAttachment(resolver, uri) } catch (_: Throwable) { null } @@ -160,7 +156,10 @@ private fun ChatThreadSelector( mainSessionKey: String, onSelectSession: (String) -> Unit, ) { - val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) + val sessionOptions = + remember(sessionKey, sessions, mainSessionKey) { + resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) + } Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), @@ -214,24 +213,3 @@ data class PendingImageAttachment( val mimeType: String, val base64: String, ) - -private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment { - val mimeType = resolver.getType(uri) ?: "image/*" - val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/') - val bytes = - withContext(Dispatchers.IO) { - resolver.openInputStream(uri)?.use { input -> - val out = ByteArrayOutputStream() - input.copyTo(out) - out.toByteArray() - } ?: ByteArray(0) - } - if (bytes.isEmpty()) throw IllegalStateException("empty attachment") - val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) - return PendingImageAttachment( - id = uri.toString() + "#" + System.currentTimeMillis().toString(), - fileName = fileName, - mimeType = mimeType, - base64 = base64, - ) -} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt new file mode 100644 index 00000000000..936bd526eb8 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt @@ -0,0 +1,81 @@ +package ai.openclaw.app.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class ChatControllerMessageIdentityTest { + @Test + fun reconcileMessageIdsReusesMatchingIdsAcrossHistoryReload() { + val previous = + listOf( + ChatMessage( + id = "msg-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ChatMessage( + id = "msg-2", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hi")), + timestampMs = 2000L, + ), + ) + + val incoming = + listOf( + ChatMessage( + id = "new-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ChatMessage( + id = "new-2", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hi")), + timestampMs = 2000L, + ), + ) + + val reconciled = reconcileMessageIds(previous = previous, incoming = incoming) + + assertEquals(listOf("msg-1", "msg-2"), reconciled.map { it.id }) + } + + @Test + fun reconcileMessageIdsLeavesNewMessagesUntouched() { + val previous = + listOf( + ChatMessage( + id = "msg-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ) + + val incoming = + listOf( + ChatMessage( + id = "new-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ChatMessage( + id = "new-2", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "new reply")), + timestampMs = 3000L, + ), + ) + + val reconciled = reconcileMessageIds(previous = previous, incoming = incoming) + + assertEquals("msg-1", reconciled[0].id) + assertEquals("new-2", reconciled[1].id) + assertNotEquals(reconciled[0].id, reconciled[1].id) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt new file mode 100644 index 00000000000..c3d55e80494 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt @@ -0,0 +1,18 @@ +package ai.openclaw.app.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatImageCodecTest { + @Test + fun computeInSampleSizeCapsLongestEdge() { + assertEquals(4, computeInSampleSize(width = 4032, height = 3024, maxDimension = 1600)) + assertEquals(1, computeInSampleSize(width = 800, height = 600, maxDimension = 1600)) + } + + @Test + fun normalizeAttachmentFileNameForcesJpegExtension() { + assertEquals("photo.jpg", normalizeAttachmentFileName("photo.png")) + assertEquals("image.jpg", normalizeAttachmentFileName("")) + } +} diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index bf67b685710..65688a7fc7a 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -8035,7 +8035,21 @@ "storage" ], "label": "Browser Profile Driver", - "help": "Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.", + "help": "Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome DevTools MCP attachment.", + "hasChildren": false + }, + { + "path": "browser.profiles.*.userDataDir", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "storage" + ], + "label": "Browser Profile User Data Dir", + "help": "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.", "hasChildren": false }, { @@ -32140,6 +32154,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.telegram.accounts.*.silentErrorReplies", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.telegram.accounts.*.streaming", "kind": "channel", @@ -34137,6 +34161,21 @@ "help": "Minimum retry delay in ms for Telegram outbound calls.", "hasChildren": false }, + { + "path": "channels.telegram.silentErrorReplies", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "channels", + "network" + ], + "label": "Telegram Silent Error Replies", + "help": "When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.", + "hasChildren": false + }, { "path": "channels.telegram.streaming", "kind": "channel", @@ -51938,6 +51977,48 @@ "help": "Resolved npm dist integrity hash for the fetched artifact (if reported by npm).", "hasChildren": false }, + { + "path": "plugins.installs.*.marketplaceName", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Marketplace Name", + "help": "Marketplace display name recorded for marketplace-backed plugin installs (if available).", + "hasChildren": false + }, + { + "path": "plugins.installs.*.marketplacePlugin", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Marketplace Plugin", + "help": "Plugin entry name inside the source marketplace, used for later updates.", + "hasChildren": false + }, + { + "path": "plugins.installs.*.marketplaceSource", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Marketplace Source", + "help": "Original marketplace source used to resolve the install (for example a repo path or Git URL).", + "hasChildren": false + }, { "path": "plugins.installs.*.resolvedAt", "kind": "core", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 34c4f9d5378..d8d82d7bb7a 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5098} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5104} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -707,7 +707,8 @@ {"recordType":"path","path":"browser.profiles.*.cdpPort","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP Port","help":"Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.","hasChildren":false} {"recordType":"path","path":"browser.profiles.*.cdpUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP URL","help":"Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.","hasChildren":false} {"recordType":"path","path":"browser.profiles.*.color","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Accent Color","help":"Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.","hasChildren":false} -{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.","hasChildren":false} +{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome DevTools MCP attachment.","hasChildren":false} +{"recordType":"path","path":"browser.profiles.*.userDataDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile User Data Dir","help":"Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.","hasChildren":false} {"recordType":"path","path":"browser.remoteCdpHandshakeTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Handshake Timeout (ms)","help":"Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.","hasChildren":false} {"recordType":"path","path":"browser.remoteCdpTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Timeout (ms)","help":"Timeout in milliseconds for connecting to a remote CDP endpoint before failing the browser attach attempt. Increase for high-latency tunnels, or lower for faster failure detection.","hasChildren":false} {"recordType":"path","path":"browser.snapshotDefaults","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Browser Snapshot Defaults","help":"Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.","hasChildren":true} @@ -2903,6 +2904,7 @@ {"recordType":"path","path":"channels.telegram.accounts.*.retry.jitter","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.retry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.retry.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.telegram.accounts.*.silentErrorReplies","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.streaming","kind":"channel","type":["boolean","string"],"required":false,"enumValues":["off","partial","block","progress"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.streamMode","kind":"channel","type":"string","required":false,"enumValues":["off","partial","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3080,6 +3082,7 @@ {"recordType":"path","path":"channels.telegram.retry.jitter","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","reliability"],"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays.","hasChildren":false} {"recordType":"path","path":"channels.telegram.retry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","performance","reliability"],"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls.","hasChildren":false} {"recordType":"path","path":"channels.telegram.retry.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","reliability"],"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls.","hasChildren":false} +{"recordType":"path","path":"channels.telegram.silentErrorReplies","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.","hasChildren":false} {"recordType":"path","path":"channels.telegram.streaming","kind":"channel","type":["boolean","string"],"required":false,"enumValues":["off","partial","block","progress"],"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\" (default: \"partial\"). \"progress\" maps to \"partial\" on Telegram. Legacy boolean/streamMode keys are auto-mapped.","hasChildren":false} {"recordType":"path","path":"channels.telegram.streamMode","kind":"channel","type":"string","required":false,"enumValues":["off","partial","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -4506,6 +4509,9 @@ {"recordType":"path","path":"plugins.installs.*.installedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Install Time","help":"ISO timestamp of last install/update.","hasChildren":false} {"recordType":"path","path":"plugins.installs.*.installPath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Plugin Install Path","help":"Resolved install directory (usually ~/.openclaw/extensions/).","hasChildren":false} {"recordType":"path","path":"plugins.installs.*.integrity","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Integrity","help":"Resolved npm dist integrity hash for the fetched artifact (if reported by npm).","hasChildren":false} +{"recordType":"path","path":"plugins.installs.*.marketplaceName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Name","help":"Marketplace display name recorded for marketplace-backed plugin installs (if available).","hasChildren":false} +{"recordType":"path","path":"plugins.installs.*.marketplacePlugin","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Plugin","help":"Plugin entry name inside the source marketplace, used for later updates.","hasChildren":false} +{"recordType":"path","path":"plugins.installs.*.marketplaceSource","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Source","help":"Original marketplace source used to resolve the install (for example a repo path or Git URL).","hasChildren":false} {"recordType":"path","path":"plugins.installs.*.resolvedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolution Time","help":"ISO timestamp when npm package metadata was last resolved for this install record.","hasChildren":false} {"recordType":"path","path":"plugins.installs.*.resolvedName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Package Name","help":"Resolved npm package name from the fetched artifact.","hasChildren":false} {"recordType":"path","path":"plugins.installs.*.resolvedSpec","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Package Spec","help":"Resolved exact npm spec (@) from the fetched artifact.","hasChildren":false} diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 42af08f84f3..c5cb5ab9984 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -91,6 +91,7 @@ Use the built-in `user` profile, or create your own `existing-session` profile: ```bash openclaw browser --browser-profile user tabs openclaw browser create-profile --name chrome-live --driver existing-session +openclaw browser create-profile --name brave-live --driver existing-session --user-data-dir "~/Library/Application Support/BraveSoftware/Brave-Browser" openclaw browser --browser-profile chrome-live tabs ``` diff --git a/docs/cli/index.md b/docs/cli/index.md index ded970cde9d..9c4b58d1c35 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -284,7 +284,8 @@ Manage extensions and their config: - `openclaw plugins list` — discover plugins (use `--json` for machine output). - `openclaw plugins info ` — show details for a plugin. -- `openclaw plugins install ` — install a plugin (or add a plugin path to `plugins.load.paths`). +- `openclaw plugins install ` — install a plugin (or add a plugin path to `plugins.load.paths`). +- `openclaw plugins marketplace list ` — list marketplace entries before install. - `openclaw plugins enable ` / `disable ` — toggle `plugins.entries..enabled`. - `openclaw plugins doctor` — report plugin load errors. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 4d9d1e8e80d..5e551a9c64f 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)" +summary: "CLI reference for `openclaw plugins` (list, install, marketplace, uninstall, enable/disable, doctor)" read_when: - You want to install or manage Gateway plugins or compatible bundles - You want to debug plugin load failures @@ -28,6 +28,7 @@ openclaw plugins uninstall openclaw plugins doctor openclaw plugins update openclaw plugins update --all +openclaw plugins marketplace list ``` Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to @@ -46,6 +47,8 @@ capabilities. ```bash openclaw plugins install openclaw plugins install --pin +openclaw plugins install @ +openclaw plugins install --marketplace ``` Security note: treat plugin installs like running code. Prefer pinned versions. @@ -65,6 +68,31 @@ name, use an explicit scoped spec (for example `@scope/diffs`). Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. +Claude marketplace installs are also supported. + +Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's +local registry cache at `~/.claude/plugins/known_marketplaces.json`: + +```bash +openclaw plugins marketplace list +openclaw plugins install @ +``` + +Use `--marketplace` when you want to pass the marketplace source explicitly: + +```bash +openclaw plugins install --marketplace +openclaw plugins install --marketplace +openclaw plugins install --marketplace ./my-marketplace +``` + +Marketplace sources can be: + +- a Claude known-marketplace name from `~/.claude/plugins/known_marketplaces.json` +- a local marketplace root or `marketplace.json` path +- a GitHub repo shorthand such as `owner/repo` +- a git URL + For local paths and archives, OpenClaw auto-detects: - native OpenClaw plugins (`openclaw.plugin.json`) @@ -114,7 +142,8 @@ openclaw plugins update --all openclaw plugins update --dry-run ``` -Updates only apply to plugins installed from npm (tracked in `plugins.installs`). +Updates apply to tracked installs in `plugins.installs`, currently npm and +marketplace installs. When a stored integrity hash exists and the fetched artifact hash changes, OpenClaw prints a warning and asks for confirmation before proceeding. Use diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index a46f342a360..170c0a94219 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2443,6 +2443,12 @@ See [Plugins](/tools/plugin). openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + brave: { + driver: "existing-session", + attachOnly: true, + userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, color: "#FF4500", @@ -2463,6 +2469,8 @@ See [Plugins](/tools/plugin). - In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions. - Remote profiles are attach-only (start/stop/reset disabled). - `existing-session` profiles are host-only and use Chrome MCP instead of CDP. +- `existing-session` profiles can set `userDataDir` to target a specific + Chromium-based browser profile such as Brave or Edge. - Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary. - Control service: loopback only (port derived from `gateway.port`, default `18791`). - `extraArgs` appends extra launch flags to local Chromium startup (for example diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 6c0711c7aea..78430476051 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -155,18 +155,20 @@ normalizes it to the current host-local Chrome MCP attach model: Doctor also audits the host-local Chrome MCP path when you use `defaultProfile: "user"` or a configured `existing-session` profile: -- checks whether Google Chrome is installed on the same host +- checks whether Google Chrome is installed on the same host for default + auto-connect profiles - checks the detected Chrome version and warns when it is below Chrome 144 -- reminds you to enable remote debugging in Chrome at - `chrome://inspect/#remote-debugging` +- reminds you to enable remote debugging in the browser inspect page (for + example `chrome://inspect/#remote-debugging`, `brave://inspect/#remote-debugging`, + or `edge://inspect/#remote-debugging`) Doctor cannot enable the Chrome-side setting for you. Host-local Chrome MCP still requires: -- Google Chrome 144+ on the gateway/node host -- Chrome running locally -- remote debugging enabled in Chrome -- approving the first attach consent prompt in Chrome +- a Chromium-based browser 144+ on the gateway/node host +- the browser running locally +- remote debugging enabled in that browser +- approving the first attach consent prompt in the browser This check does **not** apply to Docker, sandbox, remote-browser, or other headless flows. Those continue to use raw CDP. diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index b5f92f8f5ee..2fad626ccfe 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -259,12 +259,19 @@ openclaw plugins install ./my-codex-bundle openclaw plugins install ./my-claude-bundle openclaw plugins install ./my-cursor-bundle openclaw plugins install ./my-bundle.tgz +openclaw plugins marketplace list +openclaw plugins install @ openclaw plugins info my-bundle ``` If the directory is a native OpenClaw plugin/package, the native install path still wins. +For Claude marketplace names, OpenClaw reads the local Claude known-marketplace +registry at `~/.claude/plugins/known_marketplaces.json`. Marketplace entries +can resolve to bundle-compatible directories/archives or to native plugin +sources; after resolution, the normal install rules still apply. + ## Troubleshooting ### Bundle is detected but capabilities do not run diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 19ee23a25ca..0b8f89bc3d8 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -88,6 +88,12 @@ Browser settings live in `~/.openclaw/openclaw.json`. attachOnly: true, color: "#00AA00", }, + brave: { + driver: "existing-session", + attachOnly: true, + userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, }, @@ -114,6 +120,8 @@ Notes: - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP. - `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do not set `cdpUrl` for that driver. +- Set `browser.profiles..userDataDir` when an existing-session profile + should attach to a non-default Chromium user profile such as Brave or Edge. ## Use Brave (or another Chromium-based browser) @@ -289,11 +297,11 @@ Defaults: All control endpoints accept `?profile=`; the CLI uses `--browser-profile`. -## Chrome existing-session via MCP +## Existing-session via Chrome DevTools MCP -OpenClaw can also attach to a running Chrome profile through the official -Chrome DevTools MCP server. This reuses the tabs and login state already open in -that Chrome profile. +OpenClaw can also attach to a running Chromium-based browser profile through the +official Chrome DevTools MCP server. This reuses the tabs and login state +already open in that browser profile. Official background and setup references: @@ -305,13 +313,41 @@ Built-in profile: - `user` Optional: create your own custom existing-session profile if you want a -different name or color. +different name, color, or browser data directory. -Then in Chrome: +Default behavior: -1. Open `chrome://inspect/#remote-debugging` -2. Enable remote debugging -3. Keep Chrome running and approve the connection prompt when OpenClaw attaches +- The built-in `user` profile uses Chrome MCP auto-connect, which targets the + default local Google Chrome profile. + +Use `userDataDir` for Brave, Edge, Chromium, or a non-default Chrome profile: + +```json5 +{ + browser: { + profiles: { + brave: { + driver: "existing-session", + attachOnly: true, + userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, + }, + }, +} +``` + +Then in the matching browser: + +1. Open that browser's inspect page for remote debugging. +2. Enable remote debugging. +3. Keep the browser running and approve the connection prompt when OpenClaw attaches. + +Common inspect pages: + +- Chrome: `chrome://inspect/#remote-debugging` +- Brave: `brave://inspect/#remote-debugging` +- Edge: `edge://inspect/#remote-debugging` Live attach smoke test: @@ -327,17 +363,17 @@ What success looks like: - `status` shows `driver: existing-session` - `status` shows `transport: chrome-mcp` - `status` shows `running: true` -- `tabs` lists your already-open Chrome tabs +- `tabs` lists your already-open browser tabs - `snapshot` returns refs from the selected live tab What to check if attach does not work: -- Chrome is version `144+` -- remote debugging is enabled at `chrome://inspect/#remote-debugging` -- Chrome showed and you accepted the attach consent prompt +- the target Chromium-based browser is version `144+` +- remote debugging is enabled in that browser's inspect page +- the browser showed and you accepted the attach consent prompt - `openclaw doctor` migrates old extension-based browser config and checks that - Chrome is installed locally with a compatible version, but it cannot enable - Chrome-side remote debugging for you + Chrome is installed locally for default auto-connect profiles, but it cannot + enable browser-side remote debugging for you Agent use: @@ -351,10 +387,11 @@ Notes: - This path is higher-risk than the isolated `openclaw` profile because it can act inside your signed-in browser session. -- OpenClaw does not launch Chrome for this driver; it attaches to an existing - session only. -- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not - the legacy default-profile remote debugging port workflow. +- OpenClaw does not launch the browser for this driver; it attaches to an + existing session only. +- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here. If + `userDataDir` is set, OpenClaw passes it through to target that explicit + Chromium user data directory. - Existing-session screenshots support page captures and `--ref` element captures from snapshots, but not CSS `--element` selectors. - Existing-session `wait --url` supports exact, substring, and glob patterns diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 9269e8b1faf..ec0247c8d72 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -57,6 +57,18 @@ openclaw plugins install ./my-bundle openclaw plugins install ./my-bundle.tgz ``` +For Claude marketplace installs, list the marketplace first, then install by +marketplace entry name: + +```bash +openclaw plugins marketplace list +openclaw plugins install @ +``` + +OpenClaw resolves known Claude marketplace names from +`~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit +marketplace source with `--marketplace`. + ## Architecture OpenClaw's plugin system has four layers: @@ -94,6 +106,10 @@ OpenClaw also recognizes two compatible external bundle layouts: component layout without a manifest - Cursor-style bundles: `.cursor-plugin/plugin.json` +Claude marketplace entries can point at any of these compatible bundles, or at +native OpenClaw plugin sources. OpenClaw resolves the marketplace entry first, +then runs the normal install path for the resolved source. + They are shown in the plugin list as `format=bundle`, with a subtype of `codex` or `claude` in verbose/info output. @@ -826,6 +842,37 @@ instead of the full plugin entry. This keeps startup and setup lighter when your main plugin entry also wires tools, hooks, or other runtime-only code. +Optional: `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` +can opt a channel plugin into the same `setupEntry` path during the gateway's +pre-listen startup phase, even when the channel is already configured. + +Use this only when `setupEntry` fully covers the startup surface that must exist +before the gateway starts listening. In practice, that means the setup entry +must register every channel-owned capability that startup depends on, such as: + +- channel registration itself +- any HTTP routes that must be available before the gateway starts listening +- any gateway methods, tools, or services that must exist during that same window + +If your full entry still owns any required startup capability, do not enable +this flag. Keep the plugin on the default behavior and let OpenClaw load the +full entry during startup. + +Example: + +```json +{ + "name": "@scope/my-channel", + "openclaw": { + "extensions": ["./index.ts"], + "setupEntry": "./setup-entry.ts", + "startup": { + "deferConfiguredChannelFullLoadUntilAfterListen": true + } + } +} +``` + ### Channel catalog metadata Channel plugins can advertise setup/discovery metadata via `openclaw.channel` and @@ -1736,6 +1783,7 @@ Publishing contract: - Plugin `package.json` must include `openclaw.extensions` with one or more entry files. - Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel setup. +- Optional: `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` may opt a channel plugin into using `setupEntry` during pre-listen gateway startup, but only when that setup entry completely covers the plugin's startup-critical surface. - Entry files can be `.js` or `.ts` (jiti loads TS at runtime). - `openclaw plugins install ` uses `npm pack`, extracts into `~/.openclaw/extensions//`, and enables it in config. - Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`. diff --git a/extensions/bluebubbles/src/actions.runtime.ts b/extensions/bluebubbles/src/actions.runtime.ts new file mode 100644 index 00000000000..53285c19f17 --- /dev/null +++ b/extensions/bluebubbles/src/actions.runtime.ts @@ -0,0 +1,13 @@ +export { sendBlueBubblesAttachment } from "./attachments.js"; +export { + addBlueBubblesParticipant, + editBlueBubblesMessage, + leaveBlueBubblesChat, + removeBlueBubblesParticipant, + renameBlueBubblesChat, + setGroupIconBlueBubbles, + unsendBlueBubblesMessage, +} from "./chat.js"; +export { resolveBlueBubblesMessageId } from "./monitor.js"; +export { sendBlueBubblesReaction } from "./reactions.js"; +export { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index a8ce9f62c5f..4e6476afa3f 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -12,24 +12,18 @@ import { type ChannelMessageActionName, } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesAccount } from "./accounts.js"; -import { sendBlueBubblesAttachment } from "./attachments.js"; -import { - editBlueBubblesMessage, - unsendBlueBubblesMessage, - renameBlueBubblesChat, - setGroupIconBlueBubbles, - addBlueBubblesParticipant, - removeBlueBubblesParticipant, - leaveBlueBubblesChat, -} from "./chat.js"; -import { resolveBlueBubblesMessageId } from "./monitor.js"; import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js"; -import { sendBlueBubblesReaction } from "./reactions.js"; import { normalizeSecretInputString } from "./secret-input.js"; -import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js"; import type { BlueBubblesSendTarget } from "./types.js"; +let actionsRuntimePromise: Promise | null = null; + +function loadBlueBubblesActionsRuntime() { + actionsRuntimePromise ??= import("./actions.runtime.js"); + return actionsRuntimePromise; +} + const providerId = "bluebubbles"; function mapTarget(raw: string): BlueBubblesSendTarget { @@ -99,6 +93,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async ({ action, params, cfg, accountId, toolContext }) => { + const runtime = await loadBlueBubblesActionsRuntime(); const account = resolveBlueBubblesAccount({ cfg: cfg, accountId: accountId ?? undefined, @@ -147,7 +142,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { throw new Error(`BlueBubbles ${action} requires serverUrl and password.`); } - const resolved = await resolveChatGuidForTarget({ baseUrl, password, target }); + const resolved = await runtime.resolveChatGuidForTarget({ baseUrl, password, target }); if (!resolved) { throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`); } @@ -173,11 +168,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); + const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, { + requireKnownShortId: true, + }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const resolvedChatGuid = await resolveChatGuid(); - await sendBlueBubblesReaction({ + await runtime.sendBlueBubblesReaction({ chatGuid: resolvedChatGuid, messageGuid: messageId, emoji, @@ -218,11 +215,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); + const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, { + requireKnownShortId: true, + }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage"); - await editBlueBubblesMessage(messageId, newText, { + await runtime.editBlueBubblesMessage(messageId, newText, { ...opts, partIndex: typeof partIndex === "number" ? partIndex : undefined, backwardsCompatMessage: backwardsCompatMessage ?? undefined, @@ -242,10 +241,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); + const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, { + requireKnownShortId: true, + }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); - await unsendBlueBubblesMessage(messageId, { + await runtime.unsendBlueBubblesMessage(messageId, { ...opts, partIndex: typeof partIndex === "number" ? partIndex : undefined, }); @@ -276,10 +277,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); + const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, { + requireKnownShortId: true, + }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); - const result = await sendMessageBlueBubbles(to, text, { + const result = await runtime.sendMessageBlueBubbles(to, text, { ...opts, replyToMessageGuid: messageId, replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined, @@ -313,7 +316,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } - const result = await sendMessageBlueBubbles(to, text, { + const result = await runtime.sendMessageBlueBubbles(to, text, { ...opts, effectId, }); @@ -330,7 +333,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { throw new Error("BlueBubbles renameGroup requires displayName or name parameter."); } - await renameBlueBubblesChat(resolvedChatGuid, displayName, opts); + await runtime.renameBlueBubblesChat(resolvedChatGuid, displayName, opts); return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName }); } @@ -355,7 +358,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Decode base64 to buffer const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0)); - await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, { + await runtime.setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, { ...opts, contentType: contentType ?? undefined, }); @@ -372,7 +375,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { throw new Error("BlueBubbles addParticipant requires address or participant parameter."); } - await addBlueBubblesParticipant(resolvedChatGuid, address, opts); + await runtime.addBlueBubblesParticipant(resolvedChatGuid, address, opts); return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid }); } @@ -386,7 +389,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { throw new Error("BlueBubbles removeParticipant requires address or participant parameter."); } - await removeBlueBubblesParticipant(resolvedChatGuid, address, opts); + await runtime.removeBlueBubblesParticipant(resolvedChatGuid, address, opts); return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid }); } @@ -396,7 +399,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { assertPrivateApiEnabled(); const resolvedChatGuid = await resolveChatGuid(); - await leaveBlueBubblesChat(resolvedChatGuid, opts); + await runtime.leaveBlueBubblesChat(resolvedChatGuid, opts); return jsonResult({ ok: true, left: resolvedChatGuid }); } @@ -427,7 +430,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter."); } - const result = await sendBlueBubblesAttachment({ + const result = await runtime.sendBlueBubblesAttachment({ to, buffer, filename, diff --git a/extensions/bluebubbles/src/channel.runtime.ts b/extensions/bluebubbles/src/channel.runtime.ts new file mode 100644 index 00000000000..32bf567dcf5 --- /dev/null +++ b/extensions/bluebubbles/src/channel.runtime.ts @@ -0,0 +1,6 @@ +export { sendBlueBubblesMedia } from "./media-send.js"; +export { resolveBlueBubblesMessageId } from "./monitor.js"; +export { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; +export { type BlueBubblesProbe, probeBlueBubbles } from "./probe.js"; +export { sendMessageBlueBubbles } from "./send.js"; +export { blueBubblesSetupWizard } from "./setup-surface.js"; diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index d6d1a3130fb..2fe2fc3f3fb 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -25,12 +25,8 @@ import { resolveDefaultBlueBubblesAccountId, } from "./accounts.js"; import { bluebubblesMessageActions } from "./actions.js"; +import type { BlueBubblesProbe } from "./channel.runtime.js"; import { BlueBubblesConfigSchema } from "./config-schema.js"; -import { sendBlueBubblesMedia } from "./media-send.js"; -import { resolveBlueBubblesMessageId } from "./monitor.js"; -import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; -import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; -import { sendMessageBlueBubbles } from "./send.js"; import { blueBubblesSetupAdapter } from "./setup-core.js"; import { blueBubblesSetupWizard } from "./setup-surface.js"; import { @@ -41,6 +37,13 @@ import { parseBlueBubblesTarget, } from "./targets.js"; +let blueBubblesChannelRuntimePromise: Promise | null = null; + +function loadBlueBubblesChannelRuntime() { + blueBubblesChannelRuntimePromise ??= import("./channel.runtime.js"); + return blueBubblesChannelRuntimePromise; +} + const meta = { id: "bluebubbles", label: "BlueBubbles", @@ -221,7 +224,9 @@ export const bluebubblesPlugin: ChannelPlugin = { idLabel: "bluebubblesSenderId", normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), notifyApproval: async ({ cfg, id }) => { - await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, { + await ( + await loadBlueBubblesChannelRuntime() + ).sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, { cfg: cfg, }); }, @@ -240,12 +245,13 @@ export const bluebubblesPlugin: ChannelPlugin = { return { ok: true, to: trimmed }; }, sendText: async ({ cfg, to, text, accountId, replyToId }) => { + const runtime = await loadBlueBubblesChannelRuntime(); const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; // Resolve short ID (e.g., "5") to full UUID const replyToMessageGuid = rawReplyToId - ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) + ? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) : ""; - const result = await sendMessageBlueBubbles(to, text, { + const result = await runtime.sendMessageBlueBubbles(to, text, { cfg: cfg, accountId: accountId ?? undefined, replyToMessageGuid: replyToMessageGuid || undefined, @@ -253,6 +259,7 @@ export const bluebubblesPlugin: ChannelPlugin = { return { channel: "bluebubbles", ...result }; }, sendMedia: async (ctx) => { + const runtime = await loadBlueBubblesChannelRuntime(); const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx; const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { mediaPath?: string; @@ -262,7 +269,7 @@ export const bluebubblesPlugin: ChannelPlugin = { caption?: string; }; const resolvedCaption = caption ?? text; - const result = await sendBlueBubblesMedia({ + const result = await runtime.sendBlueBubblesMedia({ cfg: cfg, to, mediaUrl, @@ -290,7 +297,7 @@ export const bluebubblesPlugin: ChannelPlugin = { buildChannelSummary: ({ snapshot }) => buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }), probeAccount: async ({ account, timeoutMs }) => - probeBlueBubbles({ + (await loadBlueBubblesChannelRuntime()).probeBlueBubbles({ baseUrl: account.baseUrl, password: account.config.password ?? null, timeoutMs, @@ -315,8 +322,9 @@ export const bluebubblesPlugin: ChannelPlugin = { }, gateway: { startAccount: async (ctx) => { + const runtime = await loadBlueBubblesChannelRuntime(); const account = ctx.account; - const webhookPath = resolveWebhookPathFromConfig(account.config); + const webhookPath = runtime.resolveWebhookPathFromConfig(account.config); const statusSink = createAccountStatusSink({ accountId: ctx.accountId, setStatus: ctx.setStatus, @@ -325,7 +333,7 @@ export const bluebubblesPlugin: ChannelPlugin = { baseUrl: account.baseUrl, }); ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`); - return monitorBlueBubblesProvider({ + return runtime.monitorBlueBubblesProvider({ account, config: ctx.cfg, runtime: ctx.runtime, diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index 04906b6fd5d..13b32f08bb1 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { discordPlugin } from "./src/channel.js"; import { setDiscordRuntime } from "./src/runtime.js"; import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js"; diff --git a/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts b/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts index 97d18985460..6421d24a61a 100644 --- a/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts +++ b/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js"; import { inboundCtxCapture as capture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js"; -import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js"; import { processDiscordMessage } from "./message-handler.process.js"; import { diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 49fe53843f3..e745063d8d0 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -21,7 +21,6 @@ import { } from "../../../../src/acp/persistent-bindings.route.js"; import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; -import { resolveCommandAuthorization } from "../../../../src/auto-reply/command-auth.js"; import type { ChatCommandDefinition, CommandArgDefinition, @@ -61,8 +60,10 @@ import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { isDiscordGroupAllowedByPolicy, + normalizeDiscordAllowList, normalizeDiscordSlug, resolveDiscordChannelConfigWithFallback, + resolveDiscordAllowListMatch, resolveDiscordGuildEntry, resolveDiscordMemberAccessState, resolveDiscordOwnerAccess, @@ -108,33 +109,26 @@ function resolveDiscordNativeCommandAllowlistAccess(params: { if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") { return { configured: false, allowed: false } as const; } - const configured = - Array.isArray(commandsAllowFrom.discord) || Array.isArray(commandsAllowFrom["*"]); - if (!configured) { + const rawAllowList = Array.isArray(commandsAllowFrom.discord) + ? commandsAllowFrom.discord + : commandsAllowFrom["*"]; + if (!Array.isArray(rawAllowList)) { return { configured: false, allowed: false } as const; } - - const from = - params.chatType === "direct" - ? `discord:${params.sender.id}` - : `discord:${params.chatType}:${params.conversationId ?? "unknown"}`; - const auth = resolveCommandAuthorization({ - ctx: { - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", - AccountId: params.accountId ?? undefined, - ChatType: params.chatType, - From: from, - SenderId: params.sender.id, - SenderUsername: params.sender.name, - SenderTag: params.sender.tag, - }, - cfg: params.cfg, - // We only want explicit commands.allowFrom authorization here. - commandAuthorized: false, + const allowList = normalizeDiscordAllowList(rawAllowList.map(String), [ + "discord:", + "user:", + "pk:", + ]); + if (!allowList) { + return { configured: true, allowed: false } as const; + } + const match = resolveDiscordAllowListMatch({ + allowList, + candidate: params.sender, + allowNameMatching: false, }); - return { configured: true, allowed: auth.isAuthorizedSender } as const; + return { configured: true, allowed: match.allowed } as const; } function buildDiscordCommandOptions(params: { diff --git a/extensions/discord/src/monitor/provider.group-policy.test.ts b/extensions/discord/src/monitor/provider.group-policy.test.ts deleted file mode 100644 index 995c6f66e31..00000000000 --- a/extensions/discord/src/monitor/provider.group-policy.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; -import { __testing } from "./provider.js"; - -describe("resolveDiscordRuntimeGroupPolicy", () => { - installProviderRuntimeGroupPolicyFallbackSuite({ - resolve: __testing.resolveDiscordRuntimeGroupPolicy, - configuredLabel: "keeps open default when channels.discord is configured", - defaultGroupPolicyUnderTest: "open", - missingConfigLabel: "fails closed when channels.discord is missing and no defaults are set", - missingDefaultLabel: "ignores explicit global defaults when provider config is missing", - }); - - it("respects explicit provider policy", () => { - const resolved = __testing.resolveDiscordRuntimeGroupPolicy({ - providerConfigPresent: false, - groupPolicy: "disabled", - }); - expect(resolved.groupPolicy).toBe("disabled"); - expect(resolved.providerMissingFallbackApplied).toBe(false); - }); -}); diff --git a/extensions/discord/src/outbound-adapter.sendpayload.test.ts b/extensions/discord/src/outbound-adapter.sendpayload.test.ts deleted file mode 100644 index ae5d86f8700..00000000000 --- a/extensions/discord/src/outbound-adapter.sendpayload.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, vi } from "vitest"; -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../src/test-utils/send-payload-contract.js"; -import { discordOutbound } from "./outbound-adapter.js"; - -function createHarness(params: { - payload: ReplyPayload; - sendResults?: Array<{ messageId: string }>; -}) { - const sendDiscord = vi.fn(); - primeSendMock(sendDiscord, { messageId: "dc-1", channelId: "123456" }, params.sendResults); - const ctx = { - cfg: {}, - to: "channel:123456", - text: "", - payload: params.payload, - deps: { - sendDiscord, - }, - }; - return { - run: async () => await discordOutbound.sendPayload!(ctx), - sendMock: sendDiscord, - to: ctx.to, - }; -} - -describe("discordOutbound sendPayload", () => { - installSendPayloadContractSuite({ - channel: "discord", - chunking: { mode: "passthrough", longTextLength: 3000 }, - createHarness, - }); -}); diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 066dcdbad12..2dc10a295fd 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } = createPluginRuntimeStore("Discord runtime not initialized"); diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index 6d5824f69ae..9ba082144e6 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -35,8 +35,10 @@ const hookMocks = vi.hoisted(() => ({ unbindThreadBindingsBySessionKey: vi.fn(() => []), })); -vi.mock("openclaw/plugin-sdk/discord", () => ({ +vi.mock("./accounts.js", () => ({ resolveDiscordAccount: hookMocks.resolveDiscordAccount, +})); +vi.mock("./monitor/thread-bindings.js", () => ({ autoBindSpawnedDiscordSubagent: hookMocks.autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey, unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey, diff --git a/extensions/discord/src/subagent-hooks.ts b/extensions/discord/src/subagent-hooks.ts index f73511dba20..c9ba7b97984 100644 --- a/extensions/discord/src/subagent-hooks.ts +++ b/extensions/discord/src/subagent-hooks.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { resolveDiscordAccount } from "./accounts.js"; import { autoBindSpawnedDiscordSubagent, diff --git a/extensions/imessage/index.ts b/extensions/imessage/index.ts index 7eb0e80b070..e87d421cf2e 100644 --- a/extensions/imessage/index.ts +++ b/extensions/imessage/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { imessagePlugin } from "./src/channel.js"; import { setIMessageRuntime } from "./src/runtime.js"; diff --git a/extensions/imessage/src/monitor/provider.group-policy.test.ts b/extensions/imessage/src/monitor/provider.group-policy.test.ts deleted file mode 100644 index d6a7b1f880b..00000000000 --- a/extensions/imessage/src/monitor/provider.group-policy.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; -import { __testing } from "./monitor-provider.js"; - -describe("resolveIMessageRuntimeGroupPolicy", () => { - installProviderRuntimeGroupPolicyFallbackSuite({ - resolve: __testing.resolveIMessageRuntimeGroupPolicy, - configuredLabel: "keeps open fallback when channels.imessage is configured", - defaultGroupPolicyUnderTest: "disabled", - missingConfigLabel: "fails closed when channels.imessage is missing and no defaults are set", - missingDefaultLabel: "ignores explicit global defaults when provider config is missing", - }); -}); diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts index 8805ce3141f..08c9b6ccbbd 100644 --- a/extensions/imessage/src/runtime.ts +++ b/extensions/imessage/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } = createPluginRuntimeStore("iMessage runtime not initialized"); diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 0609c597dc4..9f4e7eef1ea 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -1,10 +1,5 @@ import { - buildOllamaProvider, emptyPluginConfigSchema, - ensureOllamaModelPulled, - OLLAMA_DEFAULT_BASE_URL, - promptAndConfigureOllama, - configureOllamaNonInteractive, type OpenClawPluginApi, type ProviderAuthContext, type ProviderAuthMethodNonInteractiveContext, @@ -12,10 +7,15 @@ import { type ProviderDiscoveryContext, } from "openclaw/plugin-sdk/core"; import { resolveOllamaApiBase } from "../../src/agents/models-config.providers.discovery.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "../../src/agents/ollama-defaults.js"; const PROVIDER_ID = "ollama"; const DEFAULT_API_KEY = "ollama-local"; +async function loadProviderSetup() { + return await import("openclaw/plugin-sdk/ollama-setup"); +} + const ollamaPlugin = { id: "ollama", name: "Ollama Provider", @@ -34,7 +34,8 @@ const ollamaPlugin = { hint: "Cloud and local open models", kind: "custom", run: async (ctx: ProviderAuthContext): Promise => { - const result = await promptAndConfigureOllama({ + const providerSetup = await loadProviderSetup(); + const result = await providerSetup.promptAndConfigureOllama({ cfg: ctx.config, prompter: ctx.prompter, }); @@ -53,12 +54,14 @@ const ollamaPlugin = { defaultModel: `ollama/${result.defaultModelId}`, }; }, - runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => - configureOllamaNonInteractive({ + runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.configureOllamaNonInteractive({ nextConfig: ctx.config, opts: ctx.opts, runtime: ctx.runtime, - }), + }); + }, }, ], discovery: { @@ -81,7 +84,8 @@ const ollamaPlugin = { }; } - const provider = await buildOllamaProvider(explicit?.baseUrl, { + const providerSetup = await loadProviderSetup(); + const provider = await providerSetup.buildOllamaProvider(explicit?.baseUrl, { quiet: !ollamaKey && !explicit, }); if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) { @@ -115,7 +119,8 @@ const ollamaPlugin = { if (!model.startsWith("ollama/")) { return; } - await ensureOllamaModelPulled({ config, prompter }); + const providerSetup = await loadProviderSetup(); + await providerSetup.ensureOllamaModelPulled({ config, prompter }); }, }); }, diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index c0ae2c12210..999c37c6204 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -10,8 +10,8 @@ import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js" import type { OAuthCredential } from "../../src/agents/auth-profiles/types.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { normalizeProviderId } from "../../src/agents/model-selection.js"; import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js"; +import { normalizeProviderId } from "../../src/agents/provider-id.js"; import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js"; import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js"; import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 9155fb3cd30..9c93ec1bd27 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -3,7 +3,7 @@ import { type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { normalizeProviderId } from "../../src/agents/model-selection.js"; +import { normalizeProviderId } from "../../src/agents/provider-id.js"; import { applyOpenAIConfig, OPENAI_DEFAULT_MODEL, diff --git a/extensions/openshell/index.ts b/extensions/openshell/index.ts index 910abe31b44..35e00cf0f52 100644 --- a/extensions/openshell/index.ts +++ b/extensions/openshell/index.ts @@ -1,5 +1,5 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { registerSandboxBackend } from "openclaw/plugin-sdk/core"; +import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox"; import { createOpenShellSandboxBackendFactory, createOpenShellSandboxBackendManager, diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts index d87b1c92af8..847e8022e24 100644 --- a/extensions/openshell/src/backend.ts +++ b/extensions/openshell/src/backend.ts @@ -11,13 +11,13 @@ import type { SandboxBackendHandle, SandboxBackendManager, SshSandboxSession, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/sandbox"; import { createRemoteShellSandboxFsBridge, disposeSshSandboxSession, resolvePreferredOpenClawTmpDir, runSshSandboxCommand, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/sandbox"; import { buildExecRemoteCommand, buildRemoteCommand, diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts index 411166520e7..a35b6aba69f 100644 --- a/extensions/openshell/src/cli.ts +++ b/extensions/openshell/src/cli.ts @@ -4,10 +4,10 @@ import { runPluginCommandWithTimeout, shellEscape, type SshSandboxSession, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/sandbox"; import type { ResolvedOpenShellPluginConfig } from "./config.js"; -export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/core"; +export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/sandbox"; export type OpenShellExecContext = { config: ResolvedOpenShellPluginConfig; diff --git a/extensions/openshell/src/fs-bridge.ts b/extensions/openshell/src/fs-bridge.ts index 00257e81be4..195e8ec555e 100644 --- a/extensions/openshell/src/fs-bridge.ts +++ b/extensions/openshell/src/fs-bridge.ts @@ -5,7 +5,7 @@ import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/sandbox"; import type { OpenShellSandboxBackend } from "./backend.js"; import { movePathWithCopyFallback } from "./mirror.js"; diff --git a/extensions/openshell/src/remote-fs-bridge.ts b/extensions/openshell/src/remote-fs-bridge.ts index 9cc1ddf704d..eeee51b7ee6 100644 --- a/extensions/openshell/src/remote-fs-bridge.ts +++ b/extensions/openshell/src/remote-fs-bridge.ts @@ -3,7 +3,7 @@ import { type RemoteShellSandboxHandle, type SandboxContext, type SandboxFsBridge, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/sandbox"; export function createOpenShellRemoteFsBridge(params: { sandbox: SandboxContext; diff --git a/extensions/sglang/index.ts b/extensions/sglang/index.ts index 5672034ad9c..fc7522ef15b 100644 --- a/extensions/sglang/index.ts +++ b/extensions/sglang/index.ts @@ -1,15 +1,20 @@ import { - buildSglangProvider, - configureOpenAICompatibleSelfHostedProviderNonInteractive, - discoverOpenAICompatibleSelfHostedProvider, emptyPluginConfigSchema, - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth, type OpenClawPluginApi, type ProviderAuthMethodNonInteractiveContext, } from "openclaw/plugin-sdk/core"; +import { + SGLANG_DEFAULT_API_KEY_ENV_VAR, + SGLANG_DEFAULT_BASE_URL, + SGLANG_MODEL_PLACEHOLDER, + SGLANG_PROVIDER_LABEL, +} from "../../src/agents/sglang-defaults.js"; const PROVIDER_ID = "sglang"; -const DEFAULT_BASE_URL = "http://127.0.0.1:30000/v1"; + +async function loadProviderSetup() { + return await import("openclaw/plugin-sdk/self-hosted-provider-setup"); +} const sglangPlugin = { id: "sglang", @@ -25,38 +30,44 @@ const sglangPlugin = { auth: [ { id: "custom", - label: "SGLang", + label: SGLANG_PROVIDER_LABEL, hint: "Fast self-hosted OpenAI-compatible server", kind: "custom", - run: async (ctx) => - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({ + run: async (ctx) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({ cfg: ctx.config, prompter: ctx.prompter, providerId: PROVIDER_ID, - providerLabel: "SGLang", - defaultBaseUrl: DEFAULT_BASE_URL, - defaultApiKeyEnvVar: "SGLANG_API_KEY", - modelPlaceholder: "Qwen/Qwen3-8B", - }), - runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => - configureOpenAICompatibleSelfHostedProviderNonInteractive({ + providerLabel: SGLANG_PROVIDER_LABEL, + defaultBaseUrl: SGLANG_DEFAULT_BASE_URL, + defaultApiKeyEnvVar: SGLANG_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: SGLANG_MODEL_PLACEHOLDER, + }); + }, + runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.configureOpenAICompatibleSelfHostedProviderNonInteractive({ ctx, providerId: PROVIDER_ID, - providerLabel: "SGLang", - defaultBaseUrl: DEFAULT_BASE_URL, - defaultApiKeyEnvVar: "SGLANG_API_KEY", - modelPlaceholder: "Qwen/Qwen3-8B", - }), + providerLabel: SGLANG_PROVIDER_LABEL, + defaultBaseUrl: SGLANG_DEFAULT_BASE_URL, + defaultApiKeyEnvVar: SGLANG_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: SGLANG_MODEL_PLACEHOLDER, + }); + }, }, ], discovery: { order: "late", - run: async (ctx) => - discoverOpenAICompatibleSelfHostedProvider({ + run: async (ctx) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.discoverOpenAICompatibleSelfHostedProvider({ ctx, providerId: PROVIDER_ID, - buildProvider: buildSglangProvider, - }), + buildProvider: providerSetup.buildSglangProvider, + }); + }, }, wizard: { setup: { diff --git a/extensions/signal/index.ts b/extensions/signal/index.ts index e1069e466e2..0a686851120 100644 --- a/extensions/signal/index.ts +++ b/extensions/signal/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { signalPlugin } from "./src/channel.js"; import { setSignalRuntime } from "./src/runtime.js"; diff --git a/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts b/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts index 62593156756..9a6cfc0e90e 100644 --- a/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts +++ b/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MsgContext } from "../../../../src/auto-reply/templating.js"; -import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js"; import { createSignalEventHandler } from "./event-handler.js"; import { createBaseSignalEventHandlerDeps, diff --git a/extensions/signal/src/runtime.ts b/extensions/signal/src/runtime.ts index 1b004d82b8a..b7cc4160f1c 100644 --- a/extensions/signal/src/runtime.ts +++ b/extensions/signal/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } = createPluginRuntimeStore("Signal runtime not initialized"); diff --git a/extensions/slack/index.ts b/extensions/slack/index.ts index 6f5945616c7..f1147cb9c91 100644 --- a/extensions/slack/index.ts +++ b/extensions/slack/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { slackPlugin } from "./src/channel.js"; import { setSlackRuntime } from "./src/runtime.js"; diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 58fc4d77184..fc902f49558 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1,5 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/core"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks } from "./blocks-render.js"; diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index a6858e529af..a57614afaeb 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -3,10 +3,10 @@ import os from "node:os"; import path from "node:path"; import type { App } from "@slack/bolt"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js"; import type { OpenClawConfig } from "../../../../../src/config/config.js"; import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../../../src/routing/session-key.js"; -import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/extensions/slack/src/monitor/provider.group-policy.test.ts b/extensions/slack/src/monitor/provider.group-policy.test.ts deleted file mode 100644 index 392003ad5f5..00000000000 --- a/extensions/slack/src/monitor/provider.group-policy.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; -import { __testing } from "./provider.js"; - -describe("resolveSlackRuntimeGroupPolicy", () => { - installProviderRuntimeGroupPolicyFallbackSuite({ - resolve: __testing.resolveSlackRuntimeGroupPolicy, - configuredLabel: "keeps open default when channels.slack is configured", - defaultGroupPolicyUnderTest: "open", - missingConfigLabel: "fails closed when channels.slack is missing and no defaults are set", - missingDefaultLabel: "ignores explicit global defaults when provider config is missing", - }); -}); diff --git a/extensions/slack/src/monitor/provider.interop.test.ts b/extensions/slack/src/monitor/provider.interop.test.ts new file mode 100644 index 00000000000..3e761cb45f1 --- /dev/null +++ b/extensions/slack/src/monitor/provider.interop.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./provider.js"; + +describe("resolveSlackBoltInterop", () => { + class FakeApp {} + class FakeHTTPReceiver {} + + it("uses the default import when it already exposes named exports", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: { + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }, + namespaceImport: {}, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("uses nested default export when the default import is a wrapper object", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: { + default: { + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }, + }, + namespaceImport: {}, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("uses the namespace receiver when the default import is the App constructor itself", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: FakeApp, + namespaceImport: { + HTTPReceiver: FakeHTTPReceiver, + }, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("uses namespace.default when it exposes named exports", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: undefined, + namespaceImport: { + default: { + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }, + }, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("falls back to the namespace import when it exposes named exports", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: undefined, + namespaceImport: { + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("throws when the module cannot be resolved", () => { + expect(() => + __testing.resolveSlackBoltInterop({ + defaultImport: null, + namespaceImport: {}, + }), + ).toThrow("Unable to resolve @slack/bolt App/HTTPReceiver exports"); + }); +}); diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 149d33bbf15..2104a5355cf 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import SlackBolt from "@slack/bolt"; +import SlackBolt, * as SlackBoltNamespace from "@slack/bolt"; import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../../../src/auto-reply/reply/history.js"; import { @@ -46,14 +46,77 @@ import { import { registerSlackMonitorSlashCommands } from "./slash.js"; import type { MonitorSlackOpts } from "./types.js"; -const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & { - default?: typeof import("@slack/bolt"); +type SlackAppConstructor = typeof import("@slack/bolt").App; +type SlackHttpReceiverConstructor = typeof import("@slack/bolt").HTTPReceiver; +type SlackBoltResolvedExports = { + App: SlackAppConstructor; + HTTPReceiver: SlackHttpReceiverConstructor; }; -// Bun allows named imports from CJS; Node ESM doesn't. Use default+fallback for compatibility. -// Fix: Check if module has App property directly (Node 25.x ESM/CJS compat issue) -const slackBolt = - (slackBoltModule.App ? slackBoltModule : slackBoltModule.default) ?? slackBoltModule; -const { App, HTTPReceiver } = slackBolt; +type Constructor = abstract new (...args: never[]) => unknown; + +function isConstructorFunction(value: unknown): value is T { + return typeof value === "function"; +} + +function resolveSlackBoltModule(value: unknown): SlackBoltResolvedExports | null { + if (!value || typeof value !== "object") { + return null; + } + const app = Reflect.get(value, "App"); + const httpReceiver = Reflect.get(value, "HTTPReceiver"); + if ( + !isConstructorFunction(app) || + !isConstructorFunction(httpReceiver) + ) { + return null; + } + return { + App: app, + HTTPReceiver: httpReceiver, + }; +} + +function resolveSlackBoltInterop(params: { + defaultImport: unknown; + namespaceImport: unknown; +}): SlackBoltResolvedExports { + const { defaultImport, namespaceImport } = params; + const nestedDefault = + defaultImport && typeof defaultImport === "object" + ? Reflect.get(defaultImport, "default") + : undefined; + const namespaceDefault = + namespaceImport && typeof namespaceImport === "object" + ? Reflect.get(namespaceImport, "default") + : undefined; + const namespaceReceiver = + namespaceImport && typeof namespaceImport === "object" + ? Reflect.get(namespaceImport, "HTTPReceiver") + : undefined; + const directModule = + resolveSlackBoltModule(defaultImport) ?? + resolveSlackBoltModule(nestedDefault) ?? + resolveSlackBoltModule(namespaceDefault) ?? + resolveSlackBoltModule(namespaceImport); + if (directModule) { + return directModule; + } + if ( + isConstructorFunction(defaultImport) && + isConstructorFunction(namespaceReceiver) + ) { + return { + App: defaultImport, + HTTPReceiver: namespaceReceiver, + }; + } + throw new TypeError("Unable to resolve @slack/bolt App/HTTPReceiver exports"); +} + +const { App, HTTPReceiver } = resolveSlackBoltInterop({ + defaultImport: SlackBolt, + namespaceImport: SlackBoltNamespace, +}); const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000; @@ -515,6 +578,7 @@ export const __testing = { publishSlackDisconnectedStatus, resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, + resolveSlackBoltInterop, getSocketEmitter, waitForSlackSocketDisconnect, }; diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts index fd1a2ba17c6..313f472eec4 100644 --- a/extensions/slack/src/runtime.ts +++ b/extensions/slack/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } = createPluginRuntimeStore("Slack runtime not initialized"); diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index b45f8c355e4..4e2b9a27890 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -5,14 +5,6 @@ vi.mock("./webhook-handler.js", () => ({ createWebhookHandler: vi.fn(() => vi.fn()), })); -vi.mock("zod", () => ({ - z: { - object: vi.fn(() => ({ - passthrough: vi.fn(() => ({ _type: "zod-schema" })), - })), - }, -})); - const { createSynologyChatPlugin } = await import("./channel.js"); describe("createSynologyChatPlugin", () => { diff --git a/extensions/telegram/index.ts b/extensions/telegram/index.ts index a2492fca87d..d47ae46b6ce 100644 --- a/extensions/telegram/index.ts +++ b/extensions/telegram/index.ts @@ -1,5 +1,5 @@ -import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { telegramPlugin } from "./src/channel.js"; import { setTelegramRuntime } from "./src/runtime.js"; diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 156d9296ae7..ea1c098e7b6 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -298,6 +298,66 @@ describe("dispatchTelegramMessage draft streaming", () => { ); }); + it("sends error replies silently when silentErrorReplies is enabled", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext(), + telegramCfg: { silentErrorReplies: true }, + }); + + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + silent: true, + replies: [expect.objectContaining({ isError: true })], + }), + ); + }); + + it("keeps error replies notifying by default", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ context: createContext() }); + + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + silent: false, + replies: [expect.objectContaining({ isError: true })], + }), + ); + }); + + it("keeps fallback replies silent after an error reply is skipped", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + dispatcherOptions.onSkip?.( + { text: "oops", isError: true }, + { kind: "final", reason: "empty" }, + ); + return { queuedFinal: false }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext(), + telegramCfg: { silentErrorReplies: true }, + }); + + expect(deliverReplies).toHaveBeenLastCalledWith( + expect.objectContaining({ + silent: true, + replies: [expect.objectContaining({ text: expect.any(String) })], + }), + ); + }); + it("keeps block streaming enabled when session reasoning level is on", async () => { loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" }, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index a9c0e625508..9b603393450 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -465,6 +465,7 @@ export const dispatchTelegramMessage = async ({ linkPreview: telegramCfg.linkPreview, replyQuoteText, }; + const silentErrorReplies = telegramCfg.silentErrorReplies === true; const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => { if (payload.text === text) { return payload; @@ -476,6 +477,7 @@ export const dispatchTelegramMessage = async ({ ...deliveryBaseOptions, replies: [payload], onVoiceRecording: sendRecordVoice, + silent: silentErrorReplies && payload.isError === true, }); if (result.delivered) { deliveryState.markDelivered(); @@ -513,6 +515,7 @@ export const dispatchTelegramMessage = async ({ }); let queuedFinal = false; + let hadErrorReplyFailureOrSkip = false; if (statusReactionController) { void statusReactionController.setThinking(); @@ -539,6 +542,9 @@ export const dispatchTelegramMessage = async ({ ...prefixOptions, typingCallbacks, deliver: async (payload, info) => { + if (payload.isError === true) { + hadErrorReplyFailureOrSkip = true; + } if (info.kind === "final") { // Assistant callbacks are fire-and-forget; ensure queued boundary // rotations/partials are applied before final delivery mapping. @@ -652,7 +658,10 @@ export const dispatchTelegramMessage = async ({ await flushBufferedFinalAnswer(); } }, - onSkip: (_payload, info) => { + onSkip: (payload, info) => { + if (payload.isError === true) { + hadErrorReplyFailureOrSkip = true; + } if (info.reason !== "silent") { deliveryState.markNonSilentSkip(); } @@ -809,6 +818,7 @@ export const dispatchTelegramMessage = async ({ const result = await deliverReplies({ replies: [{ text: fallbackText }], ...deliveryBaseOptions, + silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip), }); sentFallback = result.delivered; } 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 db3fdc23bba..6160afccf01 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -187,18 +187,20 @@ function registerAndResolveStatusHandler(params: { cfg: OpenClawConfig; allowFrom?: string[]; groupAllowFrom?: string[]; + telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; sendMessage: ReturnType; } { - const { cfg, allowFrom, groupAllowFrom, resolveTelegramGroupConfig } = params; + const { cfg, allowFrom, groupAllowFrom, telegramCfg, resolveTelegramGroupConfig } = params; return registerAndResolveCommandHandlerBase({ commandName: "status", cfg, allowFrom: allowFrom ?? ["*"], groupAllowFrom: groupAllowFrom ?? [], useAccessGroups: true, + telegramCfg, resolveTelegramGroupConfig, }); } @@ -209,6 +211,7 @@ function registerAndResolveCommandHandlerBase(params: { allowFrom: string[]; groupAllowFrom: string[]; useAccessGroups: boolean; + telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; @@ -220,6 +223,7 @@ function registerAndResolveCommandHandlerBase(params: { allowFrom, groupAllowFrom, useAccessGroups, + telegramCfg, resolveTelegramGroupConfig, } = params; const commandHandlers = new Map(); @@ -239,6 +243,7 @@ function registerAndResolveCommandHandlerBase(params: { allowFrom, groupAllowFrom, useAccessGroups, + telegramCfg, resolveTelegramGroupConfig, }), }); @@ -254,6 +259,7 @@ function registerAndResolveCommandHandler(params: { allowFrom?: string[]; groupAllowFrom?: string[]; useAccessGroups?: boolean; + telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; @@ -265,6 +271,7 @@ function registerAndResolveCommandHandler(params: { allowFrom, groupAllowFrom, useAccessGroups, + telegramCfg, resolveTelegramGroupConfig, } = params; return registerAndResolveCommandHandlerBase({ @@ -273,6 +280,7 @@ function registerAndResolveCommandHandler(params: { allowFrom: allowFrom ?? [], groupAllowFrom: groupAllowFrom ?? [], useAccessGroups: useAccessGroups ?? true, + telegramCfg, resolveTelegramGroupConfig, }); } @@ -443,6 +451,31 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); }); + it("sends native command error replies silently when silentErrorReplies is enabled", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + telegramCfg: { silentErrorReplies: true }, + }); + await handler(buildStatusCommandContext()); + + const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as + | DeliverRepliesParams + | undefined; + expect(deliveredCall).toEqual( + expect.objectContaining({ + silent: true, + replies: [expect.objectContaining({ isError: true })], + }), + ); + }); + it("routes Telegram native commands through configured ACP topic bindings", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index 0b4babb180e..33c3f04f904 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -12,6 +12,13 @@ type GetPluginCommandSpecsFn = type MatchPluginCommandFn = typeof import("../../../src/plugins/commands.js").matchPluginCommand; type ExecutePluginCommandFn = typeof import("../../../src/plugins/commands.js").executePluginCommand; +type DispatchReplyWithBufferedBlockDispatcherFn = + typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher; +type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< + ReturnType +>; +type RecordInboundSessionMetaSafeFn = + typeof import("../../../src/channels/session-meta.js").recordInboundSessionMetaSafe; type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; type NativeCommandHarness = { @@ -43,6 +50,37 @@ vi.mock("../../../src/plugins/commands.js", () => ({ executePluginCommand: pluginCommandMocks.executePluginCommand, })); +const replyPipelineMocks = vi.hoisted(() => { + const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { + queuedFinal: false, + counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], + }; + return { + finalizeInboundContext: vi.fn((ctx: unknown) => ctx), + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async () => dispatchReplyResult, + ), + createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })), + recordInboundSessionMetaSafe: vi.fn(async () => undefined), + }; +}); +export const dispatchReplyWithBufferedBlockDispatcher = + replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher; + +vi.mock("../../../src/auto-reply/reply/inbound-context.js", () => ({ + finalizeInboundContext: replyPipelineMocks.finalizeInboundContext, +})); +vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ + dispatchReplyWithBufferedBlockDispatcher: + replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, +})); +vi.mock("../../../src/channels/reply-prefix.js", () => ({ + createReplyPrefixOptions: replyPipelineMocks.createReplyPrefixOptions, +})); +vi.mock("../../../src/channels/session-meta.js", () => ({ + recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe, +})); + const deliveryMocks = vi.hoisted(() => ({ deliverReplies: vi.fn(async () => {}), })); diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index f6ebfe0dfe8..bc843293fc5 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -290,4 +290,56 @@ describe("registerTelegramNativeCommands", () => { ); expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); }); + + it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => { + const commandHandlers = new Map Promise>(); + + pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([ + { + name: "plug", + description: "Plugin command", + }, + ] as never); + pluginCommandMocks.matchPluginCommand.mockReturnValue({ + command: { key: "plug", requireAuth: false }, + args: undefined, + } as never); + pluginCommandMocks.executePluginCommand.mockResolvedValue({ + text: "plugin failed", + isError: true, + } as never); + + registerTelegramNativeCommands({ + ...buildParams({}), + bot: { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn((name: string, cb: (ctx: unknown) => Promise) => { + commandHandlers.set(name, cb); + }), + } as unknown as Parameters[0]["bot"], + telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig, + }); + + const handler = commandHandlers.get("plug"); + expect(handler).toBeTruthy(); + await handler?.({ + match: "", + message: { + message_id: 1, + date: Math.floor(Date.now() / 1000), + chat: { id: 123, type: "private" }, + from: { id: 456, username: "alice" }, + }, + }); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + silent: true, + replies: [expect.objectContaining({ isError: true })], + }), + ); + }); }); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 7dd91f6ad63..64874d1f8eb 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -363,6 +363,7 @@ export const registerTelegramNativeCommands = ({ shouldSkipUpdate, opts, }: RegisterTelegramNativeCommandsParams) => { + const silentErrorReplies = telegramCfg.silentErrorReplies === true; const boundRoute = nativeEnabled && nativeSkillsEnabled ? resolveAgentRoute({ cfg, channel: "telegram", accountId }) @@ -734,7 +735,6 @@ export const registerTelegramNativeCommands = ({ typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming : undefined; - const deliveryState = { delivered: false, skippedNonSilent: 0, @@ -766,6 +766,7 @@ export const registerTelegramNativeCommands = ({ const result = await deliverReplies({ replies: [payload], ...deliveryBaseOptions, + silent: silentErrorReplies && payload.isError === true, }); if (result.delivered) { deliveryState.delivered = true; @@ -885,6 +886,7 @@ export const registerTelegramNativeCommands = ({ await deliverReplies({ replies: [result], ...deliveryBaseOptions, + silent: silentErrorReplies && result.isError === true, }); } }); diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 9468f64c789..17f6870a964 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1,12 +1,12 @@ import { rm } from "node:fs/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../src/channels/plugins/contracts/suites.js"; import { clearPluginInteractiveHandlers, registerPluginInteractiveHandler, } from "../../../src/plugins/interactive.js"; import type { PluginInteractiveTelegramHandlerContext } from "../../../src/plugins/types.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; -import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; import { answerCallbackQuerySpy, commandSpy, diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 84d66fec12b..2dfc1c8e956 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -103,6 +103,7 @@ async function deliverTextReply(params: { replyMarkup?: ReturnType; replyQuoteText?: string; linkPreview?: boolean; + silent?: boolean; replyToId?: number; replyToMode: ReplyToMode; progress: DeliveryProgress; @@ -129,6 +130,7 @@ async function deliverTextReply(params: { textMode: "html", plainText: chunk.text, linkPreview: params.linkPreview, + silent: params.silent, replyMarkup, }, ); @@ -149,6 +151,7 @@ async function sendPendingFollowUpText(params: { text: string; replyMarkup?: ReturnType; linkPreview?: boolean; + silent?: boolean; replyToId?: number; replyToMode: ReplyToMode; progress: DeliveryProgress; @@ -167,6 +170,7 @@ async function sendPendingFollowUpText(params: { textMode: "html", plainText: chunk.text, linkPreview: params.linkPreview, + silent: params.silent, replyMarkup, }); }, @@ -196,6 +200,7 @@ async function sendTelegramVoiceFallbackText(opts: { replyToId?: number; thread?: TelegramThreadSpec | null; linkPreview?: boolean; + silent?: boolean; replyMarkup?: ReturnType; replyQuoteText?: string; }): Promise { @@ -213,6 +218,7 @@ async function sendTelegramVoiceFallbackText(opts: { textMode: "html", plainText: chunk.text, linkPreview: opts.linkPreview, + silent: opts.silent, replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined, }); if (firstDeliveredMessageId == null) { @@ -237,6 +243,7 @@ async function deliverMediaReply(params: { chunkText: ChunkTextFn; onVoiceRecording?: () => Promise | void; linkPreview?: boolean; + silent?: boolean; replyQuoteText?: string; replyMarkup?: ReturnType; replyToId?: number; @@ -282,6 +289,7 @@ async function deliverMediaReply(params: { ...buildTelegramSendParams({ replyToMessageId, thread: params.thread, + silent: params.silent, }), }; if (isGif) { @@ -375,6 +383,7 @@ async function deliverMediaReply(params: { replyToId: voiceFallbackReplyTo, thread: params.thread, linkPreview: params.linkPreview, + silent: params.silent, replyMarkup: params.replyMarkup, replyQuoteText: params.replyQuoteText, }); @@ -404,6 +413,7 @@ async function deliverMediaReply(params: { replyToId: undefined, thread: params.thread, linkPreview: params.linkPreview, + silent: params.silent, replyMarkup: params.replyMarkup, }); } @@ -451,6 +461,7 @@ async function deliverMediaReply(params: { text: pendingFollowUpText, replyMarkup: params.replyMarkup, linkPreview: params.linkPreview, + silent: params.silent, replyToId: params.replyToId, replyToMode: params.replyToMode, progress: params.progress, @@ -557,6 +568,8 @@ export async function deliverReplies(params: { onVoiceRecording?: () => Promise | void; /** Controls whether link previews are shown. Default: true (previews enabled). */ linkPreview?: boolean; + /** When true, messages are sent with disable_notification. */ + silent?: boolean; /** Optional quote text for Telegram reply_parameters. */ replyQuoteText?: string; }): Promise<{ delivered: boolean }> { @@ -637,6 +650,7 @@ export async function deliverReplies(params: { replyMarkup, replyQuoteText: params.replyQuoteText, linkPreview: params.linkPreview, + silent: params.silent, replyToId, replyToMode: params.replyToMode, progress, @@ -654,6 +668,7 @@ export async function deliverReplies(params: { chunkText, onVoiceRecording: params.onVoiceRecording, linkPreview: params.linkPreview, + silent: params.silent, replyQuoteText: params.replyQuoteText, replyMarkup, replyToId, diff --git a/extensions/telegram/src/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts index f541495aa76..d8768899c28 100644 --- a/extensions/telegram/src/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -76,6 +76,7 @@ export async function sendTelegramWithThreadFallback(params: { export function buildTelegramSendParams(opts?: { replyToMessageId?: number; thread?: TelegramThreadSpec | null; + silent?: boolean; }): Record { const threadParams = buildTelegramThreadParams(opts?.thread); const params: Record = {}; @@ -85,6 +86,9 @@ export function buildTelegramSendParams(opts?: { if (threadParams) { params.message_thread_id = threadParams.message_thread_id; } + if (opts?.silent === true) { + params.disable_notification = true; + } return params; } @@ -100,12 +104,14 @@ export async function sendTelegramText( textMode?: "markdown" | "html"; plainText?: string; linkPreview?: boolean; + silent?: boolean; replyMarkup?: ReturnType; }, ): Promise { const baseParams = buildTelegramSendParams({ replyToMessageId: opts?.replyToMessageId, thread: opts?.thread, + silent: opts?.silent, }); // Add link_preview_options when link preview is disabled. const linkPreviewEnabled = opts?.linkPreview ?? true; diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index a1dce34dceb..d9dbbf7e99b 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -211,6 +211,30 @@ describe("deliverReplies", () => { ); }); + it("sets disable_notification when silent is true", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 5, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [{ text: "hello" }], + runtime, + bot, + silent: true, + }); + + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.any(String), + expect.objectContaining({ + disable_notification: true, + }), + ); + }); + it("emits internal message:sent when session hook context is available", async () => { const runtime = createRuntime(false); const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } }); @@ -645,6 +669,36 @@ describe("deliverReplies", () => { ); }); + it("keeps disable_notification on voice fallback text when silent is true", async () => { + const runtime = createRuntime(); + const sendVoice = vi.fn().mockRejectedValue(createVoiceMessagesForbiddenError()); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 5, + chat: { id: "123" }, + }); + const bot = createBot({ sendVoice, sendMessage }); + + mockMediaLoad("note.ogg", "audio/ogg", "voice"); + + await deliverWith({ + replies: [ + { mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true }, + ], + runtime, + bot, + silent: true, + }); + + expect(sendVoice).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.stringContaining("Hello there"), + expect.objectContaining({ + disable_notification: true, + }), + ); + }); + it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => { const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({ voiceError: createVoiceMessagesForbiddenError(), diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 476260f2969..48d16361b1a 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -4,10 +4,13 @@ import type { OpenClawConfig, PluginRuntime, } from "openclaw/plugin-sdk/telegram"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import type { ResolvedTelegramAccount } from "./accounts.js"; +import * as auditModule from "./audit.js"; import { telegramPlugin } from "./channel.js"; +import * as monitorModule from "./monitor.js"; +import * as probeModule from "./probe.js"; import { setTelegramRuntime } from "./runtime.js"; function createCfg(): OpenClawConfig { @@ -53,32 +56,34 @@ function createStartAccountCtx(params: { } function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: string }) { - const monitorTelegramProvider = vi.fn(async () => undefined); - const probeTelegram = vi.fn(async () => - params?.probeOk ? { ok: true, bot: { username: params.botUsername ?? "bot" } } : { ok: false }, - ); - const collectUnmentionedGroupIds = vi.fn(() => ({ - groupIds: [] as string[], - unresolvedGroups: 0, - hasWildcardUnmentionedGroups: false, - })); - const auditGroupMembership = vi.fn(async () => ({ - ok: true, - checkedGroups: 0, - unresolvedGroups: 0, - hasWildcardUnmentionedGroups: false, - groups: [], - elapsedMs: 0, - })); + const monitorTelegramProvider = vi + .spyOn(monitorModule, "monitorTelegramProvider") + .mockImplementation(async () => undefined); + const probeTelegram = vi + .spyOn(probeModule, "probeTelegram") + .mockImplementation(async () => + params?.probeOk + ? { ok: true, bot: { username: params.botUsername ?? "bot" }, elapsedMs: 0 } + : { ok: false, elapsedMs: 0 }, + ); + const collectUnmentionedGroupIds = vi + .spyOn(auditModule, "collectTelegramUnmentionedGroupIds") + .mockImplementation(() => ({ + groupIds: [] as string[], + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + })); + const auditGroupMembership = vi + .spyOn(auditModule, "auditTelegramGroupMembership") + .mockImplementation(async () => ({ + ok: true, + checkedGroups: 0, + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + groups: [], + elapsedMs: 0, + })); setTelegramRuntime({ - channel: { - telegram: { - monitorTelegramProvider, - probeTelegram, - collectUnmentionedGroupIds, - auditGroupMembership, - }, - }, logging: { shouldLogVerbose: () => false, }, @@ -115,6 +120,10 @@ function installSendMessageRuntime( return sendMessageTelegram; } +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("telegramPlugin duplicate token guard", () => { it("marks secondary account as not configured when token is shared", async () => { const cfg = createCfg(); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index cfb5e8a5f8d..5b3ce7279c6 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -47,15 +47,17 @@ import { type ResolvedTelegramAccount, } from "./accounts.js"; import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; +import { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./audit.js"; import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import { isTelegramExecApprovalClientEnabled, resolveTelegramExecApprovalTarget, } from "./exec-approvals.js"; +import { monitorTelegramProvider } from "./monitor.js"; import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js"; import { sendTelegramPayloadMessages } from "./outbound-adapter.js"; import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; -import type { TelegramProbe } from "./probe.js"; +import { probeTelegram, type TelegramProbe } from "./probe.js"; import { getTelegramRuntime } from "./runtime.js"; import { sendTypingTelegram } from "./send.js"; import { telegramSetupAdapter } from "./setup-core.js"; @@ -697,7 +699,7 @@ export const telegramPlugin: ChannelPlugin buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => - getTelegramRuntime().channel.telegram.probeTelegram(account.token, timeoutMs, { + probeTelegram(account.token, timeoutMs, { accountId: account.accountId, proxyUrl: account.config.proxy, network: account.config.network, @@ -731,7 +733,7 @@ export const telegramPlugin: ChannelPlugin { - installProviderRuntimeGroupPolicyFallbackSuite({ - resolve: resolveTelegramRuntimeGroupPolicy, - configuredLabel: "keeps open fallback when channels.telegram is configured", - defaultGroupPolicyUnderTest: "disabled", - missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set", - missingDefaultLabel: "ignores explicit defaults when provider config is missing", - }); -}); diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts index 97ba41a3a4d..d4e15f463d9 100644 --- a/extensions/telegram/src/runtime.ts +++ b/extensions/telegram/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } = createPluginRuntimeStore("Telegram runtime not initialized"); diff --git a/extensions/vllm/index.ts b/extensions/vllm/index.ts index 571692c6585..938fb78c9bd 100644 --- a/extensions/vllm/index.ts +++ b/extensions/vllm/index.ts @@ -1,15 +1,20 @@ import { - buildVllmProvider, - configureOpenAICompatibleSelfHostedProviderNonInteractive, - discoverOpenAICompatibleSelfHostedProvider, emptyPluginConfigSchema, - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth, type OpenClawPluginApi, type ProviderAuthMethodNonInteractiveContext, } from "openclaw/plugin-sdk/core"; +import { + VLLM_DEFAULT_API_KEY_ENV_VAR, + VLLM_DEFAULT_BASE_URL, + VLLM_MODEL_PLACEHOLDER, + VLLM_PROVIDER_LABEL, +} from "../../src/agents/vllm-defaults.js"; const PROVIDER_ID = "vllm"; -const DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1"; + +async function loadProviderSetup() { + return await import("openclaw/plugin-sdk/self-hosted-provider-setup"); +} const vllmPlugin = { id: "vllm", @@ -25,38 +30,44 @@ const vllmPlugin = { auth: [ { id: "custom", - label: "vLLM", + label: VLLM_PROVIDER_LABEL, hint: "Local/self-hosted OpenAI-compatible server", kind: "custom", - run: async (ctx) => - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({ + run: async (ctx) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({ cfg: ctx.config, prompter: ctx.prompter, providerId: PROVIDER_ID, - providerLabel: "vLLM", - defaultBaseUrl: DEFAULT_BASE_URL, - defaultApiKeyEnvVar: "VLLM_API_KEY", - modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct", - }), - runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => - configureOpenAICompatibleSelfHostedProviderNonInteractive({ + providerLabel: VLLM_PROVIDER_LABEL, + defaultBaseUrl: VLLM_DEFAULT_BASE_URL, + defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: VLLM_MODEL_PLACEHOLDER, + }); + }, + runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.configureOpenAICompatibleSelfHostedProviderNonInteractive({ ctx, providerId: PROVIDER_ID, - providerLabel: "vLLM", - defaultBaseUrl: DEFAULT_BASE_URL, - defaultApiKeyEnvVar: "VLLM_API_KEY", - modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct", - }), + providerLabel: VLLM_PROVIDER_LABEL, + defaultBaseUrl: VLLM_DEFAULT_BASE_URL, + defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: VLLM_MODEL_PLACEHOLDER, + }); + }, }, ], discovery: { order: "late", - run: async (ctx) => - discoverOpenAICompatibleSelfHostedProvider({ + run: async (ctx) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.discoverOpenAICompatibleSelfHostedProvider({ ctx, providerId: PROVIDER_ID, - buildProvider: buildVllmProvider, - }), + buildProvider: providerSetup.buildVllmProvider, + }); + }, }, wizard: { setup: { diff --git a/extensions/whatsapp/index.ts b/extensions/whatsapp/index.ts index 1b19ff6775d..c0f097ddf7d 100644 --- a/extensions/whatsapp/index.ts +++ b/extensions/whatsapp/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { whatsappPlugin } from "./src/channel.js"; import { setWhatsAppRuntime } from "./src/runtime.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts index 238c675e12d..566c8a76e1e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js"; let capturedCtx: unknown; let capturedDispatchParams: unknown; diff --git a/extensions/whatsapp/src/inbound/access-control.group-policy.test.ts b/extensions/whatsapp/src/inbound/access-control.group-policy.test.ts deleted file mode 100644 index 0a508f9739b..00000000000 --- a/extensions/whatsapp/src/inbound/access-control.group-policy.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; -import { __testing } from "./access-control.js"; - -describe("resolveWhatsAppRuntimeGroupPolicy", () => { - installProviderRuntimeGroupPolicyFallbackSuite({ - resolve: __testing.resolveWhatsAppRuntimeGroupPolicy, - configuredLabel: "keeps open fallback when channels.whatsapp is configured", - defaultGroupPolicyUnderTest: "disabled", - missingConfigLabel: "fails closed when channels.whatsapp is missing and no defaults are set", - missingDefaultLabel: "ignores explicit default policy when provider config is missing", - }); -}); diff --git a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts index 81f30ea1c71..52b44de49e7 100644 --- a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts +++ b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts @@ -1,40 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../src/test-utils/send-payload-contract.js"; import { whatsappOutbound } from "./outbound-adapter.js"; -function createHarness(params: { - payload: ReplyPayload; - sendResults?: Array<{ messageId: string }>; -}) { - const sendWhatsApp = vi.fn(); - primeSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults); - const ctx = { - cfg: {}, - to: "5511999999999@c.us", - text: "", - payload: params.payload, - deps: { - sendWhatsApp, - }, - }; - return { - run: async () => await whatsappOutbound.sendPayload!(ctx), - sendMock: sendWhatsApp, - to: ctx.to, - }; -} - describe("whatsappOutbound sendPayload", () => { - installSendPayloadContractSuite({ - channel: "whatsapp", - chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, - createHarness, - }); - it("trims leading whitespace for direct text sends", async () => { const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" })); diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index c3644a531d2..07dd4e3d688 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = createPluginRuntimeStore("WhatsApp runtime not initialized"); diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 98731023653..c9f3bcdf4de 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -1,4 +1,4 @@ -import { normalizeProviderId } from "../../src/agents/model-selection.js"; +import { normalizeProviderId } from "../../src/agents/provider-id.js"; import { createPluginBackedWebSearchProvider, getScopedCredentialValue, diff --git a/extensions/zalo/src/channel.sendpayload.test.ts b/extensions/zalo/src/channel.sendpayload.test.ts deleted file mode 100644 index 27acb737f9f..00000000000 --- a/extensions/zalo/src/channel.sendpayload.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ReplyPayload } from "openclaw/plugin-sdk/zalo"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../src/test-utils/send-payload-contract.js"; -import { zaloPlugin } from "./channel.js"; - -vi.mock("./send.js", () => ({ - sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }), -})); - -function baseCtx(payload: ReplyPayload) { - return { - cfg: {}, - to: "123456789", - text: "", - payload, - }; -} - -describe("zaloPlugin outbound sendPayload", () => { - let mockedSend: ReturnType>; - - beforeEach(async () => { - const mod = await import("./send.js"); - mockedSend = vi.mocked(mod.sendMessageZalo); - mockedSend.mockClear(); - mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" }); - }); - - installSendPayloadContractSuite({ - channel: "zalo", - chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, - createHarness: ({ payload, sendResults }) => { - primeSendMock(mockedSend, { ok: true, messageId: "zl-1" }, sendResults); - return { - run: async () => await zaloPlugin.outbound!.sendPayload!(baseCtx(payload)), - sendMock: mockedSend, - to: "123456789", - }; - }, - }); -}); diff --git a/extensions/zalo/src/monitor.group-policy.test.ts b/extensions/zalo/src/monitor.group-policy.test.ts index 2ce0b1be2a2..7a44caab83d 100644 --- a/extensions/zalo/src/monitor.group-policy.test.ts +++ b/extensions/zalo/src/monitor.group-policy.test.ts @@ -2,18 +2,6 @@ import { describe, expect, it } from "vitest"; import { __testing } from "./monitor.js"; describe("zalo group policy access", () => { - it("defaults missing provider config to allowlist", () => { - const resolved = __testing.resolveZaloRuntimeGroupPolicy({ - providerConfigPresent: false, - groupPolicy: undefined, - defaultGroupPolicy: "open", - }); - expect(resolved).toEqual({ - groupPolicy: "allowlist", - providerMissingFallbackApplied: true, - }); - }); - it("blocks all group messages when policy is disabled", () => { const decision = __testing.evaluateZaloGroupAccess({ providerConfigPresent: true, diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts index 27a8adf2c0d..2c9d5240ba9 100644 --- a/extensions/zalouser/src/channel.sendpayload.test.ts +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -1,10 +1,7 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser"; import { beforeEach, describe, expect, it, vi } from "vitest"; import "./accounts.test-mocks.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../src/test-utils/send-payload-contract.js"; +import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/suites.js"; import { zalouserPlugin } from "./channel.js"; import { setZalouserRuntime } from "./runtime.js"; @@ -36,8 +33,7 @@ describe("zalouserPlugin outbound sendPayload", () => { } as never); const mod = await import("./send.js"); mockedSend = vi.mocked(mod.sendMessageZalouser); - mockedSend.mockClear(); - mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" }); + primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" }); }); it("group target delegates with isGroup=true and stripped threadId", async () => { @@ -110,19 +106,6 @@ describe("zalouserPlugin outbound sendPayload", () => { ); expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" }); }); - - installSendPayloadContractSuite({ - channel: "zalouser", - chunking: { mode: "passthrough", longTextLength: 3000 }, - createHarness: ({ payload, sendResults }) => { - primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults); - return { - run: async () => await zalouserPlugin.outbound!.sendPayload!(baseCtx(payload)), - sendMock: mockedSend, - to: "987654321", - }; - }, - }); }); describe("zalouserPlugin messaging target normalization", () => { diff --git a/openclaw.mjs b/openclaw.mjs index 248db52ea44..099c7f6a406 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import module from "node:module"; +import { fileURLToPath } from "node:url"; const MIN_NODE_MAJOR = 22; const MIN_NODE_MINOR = 12; @@ -47,6 +48,20 @@ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) { const isModuleNotFoundError = (err) => err && typeof err === "object" && "code" in err && err.code === "ERR_MODULE_NOT_FOUND"; +const isDirectModuleNotFoundError = (err, specifier) => { + if (!isModuleNotFoundError(err)) { + return false; + } + + const expectedUrl = new URL(specifier, import.meta.url); + if ("url" in err && err.url === expectedUrl.href) { + return true; + } + + const message = "message" in err && typeof err.message === "string" ? err.message : ""; + return message.includes(fileURLToPath(expectedUrl)); +}; + const installProcessWarningFilter = async () => { // Keep bootstrap warnings consistent with the TypeScript runtime. for (const specifier of ["./dist/warning-filter.js", "./dist/warning-filter.mjs"]) { @@ -57,7 +72,7 @@ const installProcessWarningFilter = async () => { return; } } catch (err) { - if (isModuleNotFoundError(err)) { + if (isDirectModuleNotFoundError(err, specifier)) { continue; } throw err; @@ -72,8 +87,8 @@ const tryImport = async (specifier) => { await import(specifier); return true; } catch (err) { - // Only swallow missing-module errors; rethrow real runtime errors. - if (isModuleNotFoundError(err)) { + // Only swallow direct entry misses; rethrow transitive resolution failures. + if (isDirectModuleNotFoundError(err, specifier)) { return false; } throw err; diff --git a/package.json b/package.json index f85bdb6e463..a0b5e9581df 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "assets/", "dist/", "docs/", + "!docs/.generated/**", + "!docs/.i18n/zh-CN.tm.jsonl", "extensions/", "skills/" ], @@ -48,6 +50,22 @@ "types": "./dist/plugin-sdk/compat.d.ts", "default": "./dist/plugin-sdk/compat.js" }, + "./plugin-sdk/ollama-setup": { + "types": "./dist/plugin-sdk/ollama-setup.d.ts", + "default": "./dist/plugin-sdk/ollama-setup.js" + }, + "./plugin-sdk/provider-setup": { + "types": "./dist/plugin-sdk/provider-setup.d.ts", + "default": "./dist/plugin-sdk/provider-setup.js" + }, + "./plugin-sdk/sandbox": { + "types": "./dist/plugin-sdk/sandbox.d.ts", + "default": "./dist/plugin-sdk/sandbox.js" + }, + "./plugin-sdk/self-hosted-provider-setup": { + "types": "./dist/plugin-sdk/self-hosted-provider-setup.d.ts", + "default": "./dist/plugin-sdk/self-hosted-provider-setup.js" + }, "./plugin-sdk/routing": { "types": "./dist/plugin-sdk/routing.d.ts", "default": "./dist/plugin-sdk/routing.js" @@ -311,6 +329,7 @@ "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", "test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts", "test:channels": "vitest run --config vitest.channels.config.ts", + "test:contracts": "pnpm test -- src/channels/plugins/contracts src/plugins/contracts", "test:coverage": "vitest run --config vitest.unit.config.ts --coverage", "test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup", "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh", @@ -323,6 +342,7 @@ "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 vitest run --config vitest.e2e.config.ts test/openshell-sandbox.e2e.test.ts", + "test:extension": "node scripts/test-extension.mjs", "test:extensions": "vitest run --config vitest.extensions.config.ts", "test:fast": "vitest run --config vitest.unit.config.ts", "test:force": "node --import tsx scripts/test-force.ts", diff --git a/scripts/check-cli-startup-memory.mjs b/scripts/check-cli-startup-memory.mjs index fcbd63d8d11..ce452d1a7ab 100644 --- a/scripts/check-cli-startup-memory.mjs +++ b/scripts/check-cli-startup-memory.mjs @@ -63,11 +63,12 @@ const cases = [ ]; function parseMaxRssMb(stderr) { - const match = stderr.match(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "m")); - if (!match) { + const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))]; + const lastMatch = matches.at(-1); + if (!lastMatch) { return null; } - return Number(match[1]) / 1024; + return Number(lastMatch[1]) / 1024; } function buildBenchEnv() { @@ -98,6 +99,9 @@ function buildBenchEnv() { // one-shot compile cache overhead, which varies across runner builds. env.NODE_DISABLE_COMPILE_CACHE = "1"; } + // Keep the benchmark on a single process so RSS reflects the actual command + // path rather than the warning-suppression respawn wrapper. + env.OPENCLAW_NO_RESPAWN = "1"; return env; } diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 91b16c36450..a6de3f4e24e 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -2,6 +2,10 @@ "index", "core", "compat", + "ollama-setup", + "provider-setup", + "sandbox", + "self-hosted-provider-setup", "routing", "telegram", "discord", diff --git a/scripts/test-extension.mjs b/scripts/test-extension.mjs new file mode 100644 index 00000000000..84fd91b0436 --- /dev/null +++ b/scripts/test-extension.mjs @@ -0,0 +1,283 @@ +#!/usr/bin/env node + +import { execFileSync, spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { channelTestRoots } from "../vitest.channel-paths.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const pnpm = "pnpm"; + +function normalizeRelative(inputPath) { + return inputPath.split(path.sep).join("/"); +} + +function isTestFile(filePath) { + return filePath.endsWith(".test.ts") || filePath.endsWith(".test.tsx"); +} + +function collectTestFiles(rootPath) { + const results = []; + const stack = [rootPath]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current || !fs.existsSync(current)) { + continue; + } + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist") { + continue; + } + stack.push(fullPath); + continue; + } + if (entry.isFile() && isTestFile(fullPath)) { + results.push(fullPath); + } + } + } + + return results.toSorted((left, right) => left.localeCompare(right)); +} + +function listChangedPaths(base, head = "HEAD") { + if (!base) { + throw new Error("A git base revision is required to list changed extensions."); + } + + return execFileSync("git", ["diff", "--name-only", base, head], { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function hasExtensionPackage(extensionId) { + return fs.existsSync(path.join(repoRoot, "extensions", extensionId, "package.json")); +} + +export function detectChangedExtensionIds(changedPaths) { + const extensionIds = new Set(); + + for (const rawPath of changedPaths) { + const relativePath = normalizeRelative(String(rawPath).trim()); + if (!relativePath) { + continue; + } + + const extensionMatch = relativePath.match(/^extensions\/([^/]+)(?:\/|$)/); + if (extensionMatch) { + extensionIds.add(extensionMatch[1]); + continue; + } + + const pairedCoreMatch = relativePath.match(/^src\/([^/]+)(?:\/|$)/); + if (pairedCoreMatch && hasExtensionPackage(pairedCoreMatch[1])) { + extensionIds.add(pairedCoreMatch[1]); + } + } + + return [...extensionIds].toSorted((left, right) => left.localeCompare(right)); +} + +export function listChangedExtensionIds(params = {}) { + const base = params.base; + const head = params.head ?? "HEAD"; + return detectChangedExtensionIds(listChangedPaths(base, head)); +} + +function resolveExtensionDirectory(targetArg, cwd = process.cwd()) { + if (targetArg) { + const asGiven = path.resolve(cwd, targetArg); + if (fs.existsSync(path.join(asGiven, "package.json"))) { + return asGiven; + } + + const byName = path.join(repoRoot, "extensions", targetArg); + if (fs.existsSync(path.join(byName, "package.json"))) { + return byName; + } + + throw new Error( + `Unknown extension target "${targetArg}". Use an extension name like "slack" or a path under extensions/.`, + ); + } + + let current = cwd; + while (true) { + if ( + normalizeRelative(path.relative(repoRoot, current)).startsWith("extensions/") && + fs.existsSync(path.join(current, "package.json")) + ) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + throw new Error( + "No extension target provided, and current working directory is not inside extensions/.", + ); +} + +export function resolveExtensionTestPlan(params = {}) { + const cwd = params.cwd ?? process.cwd(); + const targetArg = params.targetArg; + const extensionDir = resolveExtensionDirectory(targetArg, cwd); + const extensionId = path.basename(extensionDir); + const relativeExtensionDir = normalizeRelative(path.relative(repoRoot, extensionDir)); + + const roots = [relativeExtensionDir]; + const pairedCoreRoot = path.join(repoRoot, "src", extensionId); + if (fs.existsSync(pairedCoreRoot)) { + const pairedRelativeRoot = normalizeRelative(path.relative(repoRoot, pairedCoreRoot)); + if (collectTestFiles(pairedCoreRoot).length > 0) { + roots.push(pairedRelativeRoot); + } + } + + const usesChannelConfig = roots.some((root) => channelTestRoots.includes(root)); + const config = usesChannelConfig ? "vitest.channels.config.ts" : "vitest.extensions.config.ts"; + const testFiles = roots.flatMap((root) => collectTestFiles(path.join(repoRoot, root))); + + return { + config, + extensionDir: relativeExtensionDir, + extensionId, + roots, + testFiles: testFiles.map((filePath) => normalizeRelative(path.relative(repoRoot, filePath))), + }; +} + +function printUsage() { + console.error("Usage: pnpm test:extension [vitest args...]"); + console.error(" node scripts/test-extension.mjs [extension-name|path] [vitest args...]"); + console.error( + " node scripts/test-extension.mjs --list-changed --base [--head ]", + ); +} + +async function run() { + const rawArgs = process.argv.slice(2); + const dryRun = rawArgs.includes("--dry-run"); + const json = rawArgs.includes("--json"); + const listChanged = rawArgs.includes("--list-changed"); + const args = rawArgs.filter( + (arg) => arg !== "--" && arg !== "--dry-run" && arg !== "--json" && arg !== "--list-changed", + ); + + let base = ""; + let head = "HEAD"; + const passthroughArgs = []; + + if (listChanged) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--base") { + base = args[index + 1] ?? ""; + index += 1; + continue; + } + if (arg === "--head") { + head = args[index + 1] ?? "HEAD"; + index += 1; + continue; + } + passthroughArgs.push(arg); + } + } else { + passthroughArgs.push(...args); + } + + if (listChanged) { + let extensionIds; + try { + extensionIds = listChangedExtensionIds({ base, head }); + } catch (error) { + printUsage(); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + if (json) { + process.stdout.write(`${JSON.stringify({ base, head, extensionIds }, null, 2)}\n`); + } else { + for (const extensionId of extensionIds) { + console.log(extensionId); + } + } + return; + } + + let targetArg; + if (passthroughArgs[0] && !passthroughArgs[0].startsWith("-")) { + targetArg = passthroughArgs.shift(); + } + + let plan; + try { + plan = resolveExtensionTestPlan({ cwd: process.cwd(), targetArg }); + } catch (error) { + printUsage(); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + if (plan.testFiles.length === 0) { + console.error(`No tests found for ${plan.extensionDir}.`); + process.exit(1); + } + + if (dryRun) { + if (json) { + process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); + } else { + console.log(`[test-extension] ${plan.extensionId}`); + console.log(`config: ${plan.config}`); + console.log(`roots: ${plan.roots.join(", ")}`); + console.log(`tests: ${plan.testFiles.length}`); + } + return; + } + + console.log( + `[test-extension] Running ${plan.testFiles.length} test files for ${plan.extensionId} with ${plan.config}`, + ); + + const child = spawn( + pnpm, + ["exec", "vitest", "run", "--config", plan.config, ...plan.testFiles, ...passthroughArgs], + { + cwd: repoRoot, + stdio: "inherit", + shell: process.platform === "win32", + env: process.env, + }, + ); + + child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); + }); +} + +const entryHref = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; + +if (import.meta.url === entryHref) { + await run(); +} diff --git a/src/agents/agent-paths.ts b/src/agents/agent-paths.ts index cfb874d3112..15861d145a1 100644 --- a/src/agents/agent-paths.ts +++ b/src/agents/agent-paths.ts @@ -3,14 +3,13 @@ import { resolveStateDir } from "../config/paths.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; -export function resolveOpenClawAgentDir(): string { - const override = - process.env.OPENCLAW_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim(); +export function resolveOpenClawAgentDir(env: NodeJS.ProcessEnv = process.env): string { + const override = env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim(); if (override) { - return resolveUserPath(override); + return resolveUserPath(override, env); } - const defaultAgentDir = path.join(resolveStateDir(), "agents", DEFAULT_AGENT_ID, "agent"); - return resolveUserPath(defaultAgentDir); + const defaultAgentDir = path.join(resolveStateDir(env), "agents", DEFAULT_AGENT_ID, "agent"); + return resolveUserPath(defaultAgentDir, env); } export function ensureOpenClawAgentEnv(): string { diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 5d190ce1eae..5425b033dca 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -327,12 +327,16 @@ export function resolveAgentIdByWorkspacePath( return resolveAgentIdsByWorkspacePath(cfg, workspacePath)[0]; } -export function resolveAgentDir(cfg: OpenClawConfig, agentId: string) { +export function resolveAgentDir( + cfg: OpenClawConfig, + agentId: string, + env: NodeJS.ProcessEnv = process.env, +) { const id = normalizeAgentId(agentId); const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim(); if (configured) { - return resolveUserPath(configured); + return resolveUserPath(configured, env); } - const root = resolveStateDir(process.env); + const root = resolveStateDir(env); return path.join(root, "agents", id, "agent"); } diff --git a/src/agents/auth-profiles.readonly-sync.test.ts b/src/agents/auth-profiles.readonly-sync.test.ts index 2ef1c40d2f8..30a7b501a95 100644 --- a/src/agents/auth-profiles.readonly-sync.test.ts +++ b/src/agents/auth-profiles.readonly-sync.test.ts @@ -47,7 +47,10 @@ describe("auth profiles read-only external CLI sync", () => { const loaded = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true }); - expect(mocks.syncExternalCliCredentials).toHaveBeenCalled(); + expect(mocks.syncExternalCliCredentials).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ log: false }), + ); expect(loaded.profiles["qwen-portal:default"]).toMatchObject({ type: "oauth", provider: "qwen-portal", diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 2627845ed40..7e490c97c94 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -14,6 +14,10 @@ import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from ". const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default"; +type ExternalCliSyncOptions = { + log?: boolean; +}; + function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean { if (!a) { return false; @@ -60,6 +64,7 @@ function syncExternalCliCredentialsForProvider( provider: string, readCredentials: () => OAuthCredential | null, now: number, + options: ExternalCliSyncOptions, ): boolean { const existing = store.profiles[profileId]; const shouldSync = @@ -78,10 +83,12 @@ function syncExternalCliCredentialsForProvider( if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) { store.profiles[profileId] = creds; - log.info(`synced ${provider} credentials from external cli`, { - profileId, - expires: new Date(creds.expires).toISOString(), - }); + if (options.log !== false) { + log.info(`synced ${provider} credentials from external cli`, { + profileId, + expires: new Date(creds.expires).toISOString(), + }); + } return true; } @@ -94,7 +101,10 @@ function syncExternalCliCredentialsForProvider( * * Returns true if any credentials were updated. */ -export function syncExternalCliCredentials(store: AuthProfileStore): boolean { +export function syncExternalCliCredentials( + store: AuthProfileStore, + options: ExternalCliSyncOptions = {}, +): boolean { let mutated = false; const now = Date.now(); @@ -119,10 +129,12 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean { if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) { store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds; mutated = true; - log.info("synced qwen credentials from qwen cli", { - profileId: QWEN_CLI_PROFILE_ID, - expires: new Date(qwenCreds.expires).toISOString(), - }); + if (options.log !== false) { + log.info("synced qwen credentials from qwen cli", { + profileId: QWEN_CLI_PROFILE_ID, + expires: new Date(qwenCreds.expires).toISOString(), + }); + } } } @@ -134,6 +146,7 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean { "minimax-portal", () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), now, + options, ) ) { mutated = true; @@ -145,6 +158,7 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean { "openai-codex", () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), now, + options, ) ) { mutated = true; diff --git a/src/agents/auth-profiles/profiles.ts b/src/agents/auth-profiles/profiles.ts index f05808429a6..92f44302e40 100644 --- a/src/agents/auth-profiles/profiles.ts +++ b/src/agents/auth-profiles/profiles.ts @@ -1,6 +1,6 @@ import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; -import { normalizeProviderId, normalizeProviderIdForAuth } from "../model-selection.js"; +import { normalizeProviderId, normalizeProviderIdForAuth } from "../provider-id.js"; import { ensureAuthProfileStore, saveAuthProfileStore, diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 0fa050e55ec..b1362310b7f 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -381,7 +381,7 @@ function loadAuthProfileStoreForAgent( if (asStore) { // Runtime secret activation must remain read-only: // sync external CLI credentials in-memory, but never persist while readOnly. - const synced = syncExternalCliCredentials(asStore); + const synced = syncExternalCliCredentials(asStore, { log: !readOnly }); if (synced && !readOnly) { saveJsonFile(authPath, asStore); } @@ -413,7 +413,7 @@ function loadAuthProfileStoreForAgent( const mergedOAuth = mergeOAuthFileIntoStore(store); // Keep external CLI credentials visible in runtime even during read-only loads. - const syncedCli = syncExternalCliCredentials(store); + const syncedCli = syncExternalCliCredentials(store, { log: !readOnly }); const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1"; const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli); if (shouldWrite) { diff --git a/src/agents/auth-profiles/upsert-with-lock.ts b/src/agents/auth-profiles/upsert-with-lock.ts new file mode 100644 index 00000000000..965798da940 --- /dev/null +++ b/src/agents/auth-profiles/upsert-with-lock.ts @@ -0,0 +1,56 @@ +import { withFileLock } from "../../infra/file-lock.js"; +import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; +import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION } from "./constants.js"; +import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js"; +import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js"; + +function coerceAuthProfileStore(raw: unknown): AuthProfileStore { + const record = raw && typeof raw === "object" ? (raw as Record) : {}; + const profiles = + record.profiles && typeof record.profiles === "object" && !Array.isArray(record.profiles) + ? { ...(record.profiles as Record) } + : {}; + const order = + record.order && typeof record.order === "object" && !Array.isArray(record.order) + ? (record.order as Record) + : undefined; + const lastGood = + record.lastGood && typeof record.lastGood === "object" && !Array.isArray(record.lastGood) + ? (record.lastGood as Record) + : undefined; + const usageStats = + record.usageStats && typeof record.usageStats === "object" && !Array.isArray(record.usageStats) + ? (record.usageStats as Record) + : undefined; + + return { + version: + typeof record.version === "number" && Number.isFinite(record.version) + ? record.version + : AUTH_STORE_VERSION, + profiles, + ...(order ? { order } : {}), + ...(lastGood ? { lastGood } : {}), + ...(usageStats ? { usageStats } : {}), + }; +} + +export async function upsertAuthProfileWithLock(params: { + profileId: string; + credential: AuthProfileCredential; + agentDir?: string; +}): Promise { + const authPath = resolveAuthStorePath(params.agentDir); + ensureAuthStoreFile(authPath); + + try { + return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => { + const store = coerceAuthProfileStore(loadJsonFile(authPath)); + store.profiles[params.profileId] = params.credential; + saveJsonFile(authPath, store); + return store; + }); + } catch { + return null; + } +} diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index ca509f632d4..f9395373024 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -5,7 +5,12 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; import { ensureAuthProfileStore } from "./auth-profiles.js"; -import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js"; +import { + getApiKeyForModel, + hasAvailableAuthForProvider, + resolveApiKeyForProvider, + resolveEnvApiKey, +} from "./model-auth.js"; const envVar = (...parts: string[]) => parts.join("_"); @@ -206,6 +211,40 @@ describe("getApiKeyForModel", () => { ); }); + it("hasAvailableAuthForProvider('google') accepts GOOGLE_API_KEY fallback", async () => { + await withEnvAsync( + { + GEMINI_API_KEY: undefined, + GOOGLE_API_KEY: "google-test-key", // pragma: allowlist secret + }, + async () => { + await expect( + hasAvailableAuthForProvider({ + provider: "google", + store: { version: 1, profiles: {} }, + }), + ).resolves.toBe(true); + }, + ); + }); + + it("hasAvailableAuthForProvider returns false when no provider auth is available", async () => { + await withEnvAsync( + { + ZAI_API_KEY: undefined, + Z_AI_API_KEY: undefined, + }, + async () => { + await expect( + hasAvailableAuthForProvider({ + provider: "zai", + store: { version: 1, profiles: {} }, + }), + ).resolves.toBe(false); + }, + ); + }); + it("resolves Synthetic API key from env", async () => { await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => { // pragma: allowlist secret diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 9e94c51dad7..e494cc71b8c 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -487,6 +487,56 @@ export function resolveModelAuthMode( return "unknown"; } +export async function hasAvailableAuthForProvider(params: { + provider: string; + cfg?: OpenClawConfig; + preferredProfile?: string; + store?: AuthProfileStore; + agentDir?: string; +}): Promise { + const { provider, cfg, preferredProfile } = params; + const store = params.store ?? ensureAuthProfileStore(params.agentDir); + + const authOverride = resolveProviderAuthOverride(cfg, provider); + if (authOverride === "aws-sdk") { + return true; + } + + const order = resolveAuthProfileOrder({ + cfg, + store, + provider, + preferredProfile, + }); + for (const candidate of order) { + try { + const resolved = await resolveApiKeyForProfile({ + cfg, + store, + profileId: candidate, + agentDir: params.agentDir, + }); + if (resolved) { + return true; + } + } catch (err) { + log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`); + } + } + + if (resolveEnvApiKey(provider)) { + return true; + } + if (resolveUsableCustomProviderApiKey({ cfg, provider })) { + return true; + } + if (resolveSyntheticLocalProviderAuth({ cfg, provider })) { + return true; + } + + return authOverride === undefined && normalizeProviderId(provider) === "amazon-bedrock"; +} + export async function getApiKeyForModel(params: { model: Model; cfg?: OpenClawConfig; diff --git a/src/agents/model-catalog.test-harness.ts b/src/agents/model-catalog.test-harness.ts index 0c4633d6748..4343cfc40e6 100644 --- a/src/agents/model-catalog.test-harness.ts +++ b/src/agents/model-catalog.test-harness.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, vi } from "vitest"; +import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js"; import { __setModelCatalogImportForTest, resetModelCatalogCacheForTest } from "./model-catalog.js"; export type PiSdkModule = typeof import("./pi-model-discovery.js"); @@ -14,11 +15,13 @@ vi.mock("./agent-paths.js", () => ({ export function installModelCatalogTestHooks() { beforeEach(() => { resetModelCatalogCacheForTest(); + resetProviderRuntimeHookCacheForTest(); }); afterEach(() => { __setModelCatalogImportForTest(); resetModelCatalogCacheForTest(); + resetProviderRuntimeHookCacheForTest(); vi.restoreAllMocks(); }); } diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 0f8f5568618..7cdc52e641c 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -16,6 +16,12 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.js"; import { normalizeGoogleModelId } from "./model-id-normalization.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; +import { + findNormalizedProviderKey, + findNormalizedProviderValue, + normalizeProviderId, + normalizeProviderIdForAuth, +} from "./provider-id.js"; const log = createSubsystemLogger("model-selection"); @@ -60,71 +66,12 @@ export function legacyModelKey(provider: string, model: string): string | null { return rawKey === canonicalKey ? null : rawKey; } -export function normalizeProviderId(provider: string): string { - const normalized = provider.trim().toLowerCase(); - if (normalized === "z.ai" || normalized === "z-ai") { - return "zai"; - } - if (normalized === "opencode-zen") { - return "opencode"; - } - if (normalized === "opencode-go-auth") { - return "opencode-go"; - } - if (normalized === "qwen") { - return "qwen-portal"; - } - if (normalized === "kimi-code") { - return "kimi-coding"; - } - if (normalized === "bedrock" || normalized === "aws-bedrock") { - return "amazon-bedrock"; - } - // Backward compatibility for older provider naming. - if (normalized === "bytedance" || normalized === "doubao") { - return "volcengine"; - } - return normalized; -} - -/** Normalize provider ID for auth lookup. Coding-plan variants share auth with base. */ -export function normalizeProviderIdForAuth(provider: string): string { - const normalized = normalizeProviderId(provider); - if (normalized === "volcengine-plan") { - return "volcengine"; - } - if (normalized === "byteplus-plan") { - return "byteplus"; - } - return normalized; -} - -export function findNormalizedProviderValue( - entries: Record | undefined, - provider: string, -): T | undefined { - if (!entries) { - return undefined; - } - const providerKey = normalizeProviderId(provider); - for (const [key, value] of Object.entries(entries)) { - if (normalizeProviderId(key) === providerKey) { - return value; - } - } - return undefined; -} - -export function findNormalizedProviderKey( - entries: Record | undefined, - provider: string, -): string | undefined { - if (!entries) { - return undefined; - } - const providerKey = normalizeProviderId(provider); - return Object.keys(entries).find((key) => normalizeProviderId(key) === providerKey); -} +export { + findNormalizedProviderKey, + findNormalizedProviderValue, + normalizeProviderId, + normalizeProviderIdForAuth, +}; export function isCliProvider(provider: string, cfg?: OpenClawConfig): boolean { const normalized = normalizeProviderId(provider); diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index ac1dcccdb74..48927e6d5f3 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -1,5 +1,5 @@ import { resolveProviderBuiltInModelSuppression } from "../plugins/provider-runtime.js"; -import { normalizeProviderId } from "./model-selection.js"; +import { normalizeProviderId } from "./provider-id.js"; function resolveBuiltInModelSuppression(params: { provider?: string | null; id?: string | null }) { const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? ""); diff --git a/src/agents/models-config.providers.discovery.ts b/src/agents/models-config.providers.discovery.ts index a6d99afa89f..01dfb28e469 100644 --- a/src/agents/models-config.providers.discovery.ts +++ b/src/agents/models-config.providers.discovery.ts @@ -18,8 +18,15 @@ import { resolveOllamaApiBase, type OllamaTagsResponse, } from "./ollama-models.js"; +import { + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "./self-hosted-provider-defaults.js"; +import { SGLANG_DEFAULT_BASE_URL, SGLANG_PROVIDER_LABEL } from "./sglang-defaults.js"; import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js"; +import { VLLM_DEFAULT_BASE_URL, VLLM_PROVIDER_LABEL } from "./vllm-defaults.js"; export { resolveOllamaApiBase } from "./ollama-models.js"; @@ -31,19 +38,6 @@ const log = createSubsystemLogger("agents/model-providers"); const OLLAMA_SHOW_CONCURRENCY = 8; const OLLAMA_SHOW_MAX_MODELS = 200; -const OPENAI_COMPAT_LOCAL_DEFAULT_CONTEXT_WINDOW = 128000; -const OPENAI_COMPAT_LOCAL_DEFAULT_MAX_TOKENS = 8192; -const OPENAI_COMPAT_LOCAL_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const SGLANG_BASE_URL = "http://127.0.0.1:30000/v1"; - -const VLLM_BASE_URL = "http://127.0.0.1:8000/v1"; - type OpenAICompatModelsResponse = { data?: Array<{ id?: string; @@ -140,9 +134,9 @@ async function discoverOpenAICompatibleLocalModels(params: { name: modelId, reasoning: isReasoningModelHeuristic(modelId), input: ["text"], - cost: OPENAI_COMPAT_LOCAL_DEFAULT_COST, - contextWindow: params.contextWindow ?? OPENAI_COMPAT_LOCAL_DEFAULT_CONTEXT_WINDOW, - maxTokens: params.maxTokens ?? OPENAI_COMPAT_LOCAL_DEFAULT_MAX_TOKENS, + cost: SELF_HOSTED_DEFAULT_COST, + contextWindow: params.contextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS, } satisfies ModelDefinitionConfig; }); } catch (error) { @@ -197,11 +191,11 @@ export async function buildVllmProvider(params?: { baseUrl?: string; apiKey?: string; }): Promise { - const baseUrl = (params?.baseUrl?.trim() || VLLM_BASE_URL).replace(/\/+$/, ""); + const baseUrl = (params?.baseUrl?.trim() || VLLM_DEFAULT_BASE_URL).replace(/\/+$/, ""); const models = await discoverOpenAICompatibleLocalModels({ baseUrl, apiKey: params?.apiKey, - label: "vLLM", + label: VLLM_PROVIDER_LABEL, }); return { baseUrl, @@ -214,11 +208,11 @@ export async function buildSglangProvider(params?: { baseUrl?: string; apiKey?: string; }): Promise { - const baseUrl = (params?.baseUrl?.trim() || SGLANG_BASE_URL).replace(/\/+$/, ""); + const baseUrl = (params?.baseUrl?.trim() || SGLANG_DEFAULT_BASE_URL).replace(/\/+$/, ""); const models = await discoverOpenAICompatibleLocalModels({ baseUrl, apiKey: params?.apiKey, - label: "SGLang", + label: SGLANG_PROVIDER_LABEL, }); return { baseUrl, diff --git a/src/agents/ollama-defaults.ts b/src/agents/ollama-defaults.ts new file mode 100644 index 00000000000..434efeb8dcb --- /dev/null +++ b/src/agents/ollama-defaults.ts @@ -0,0 +1 @@ +export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434"; diff --git a/src/agents/ollama-models.ts b/src/agents/ollama-models.ts index 20406b3a80e..ee0fcfde447 100644 --- a/src/agents/ollama-models.ts +++ b/src/agents/ollama-models.ts @@ -1,7 +1,6 @@ import type { ModelDefinitionConfig } from "../config/types.models.js"; -import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "./ollama-defaults.js"; -export const OLLAMA_DEFAULT_BASE_URL = OLLAMA_NATIVE_BASE_URL; export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000; export const OLLAMA_DEFAULT_MAX_TOKENS = 8192; export const OLLAMA_DEFAULT_COST = { diff --git a/src/agents/ollama-stream.ts b/src/agents/ollama-stream.ts index 70a2ef33cf1..f332ad1fd83 100644 --- a/src/agents/ollama-stream.ts +++ b/src/agents/ollama-stream.ts @@ -10,6 +10,7 @@ import type { import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "./ollama-defaults.js"; import { buildAssistantMessage as buildStreamAssistantMessage, buildStreamErrorAssistantMessage, @@ -18,7 +19,7 @@ import { const log = createSubsystemLogger("ollama-stream"); -export const OLLAMA_NATIVE_BASE_URL = "http://127.0.0.1:11434"; +export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL; export function resolveOllamaBaseUrlForRun(params: { modelBaseUrl?: string; diff --git a/src/agents/provider-id.ts b/src/agents/provider-id.ts new file mode 100644 index 00000000000..354817e8a96 --- /dev/null +++ b/src/agents/provider-id.ts @@ -0,0 +1,65 @@ +export function normalizeProviderId(provider: string): string { + const normalized = provider.trim().toLowerCase(); + if (normalized === "z.ai" || normalized === "z-ai") { + return "zai"; + } + if (normalized === "opencode-zen") { + return "opencode"; + } + if (normalized === "opencode-go-auth") { + return "opencode-go"; + } + if (normalized === "qwen") { + return "qwen-portal"; + } + if (normalized === "kimi-code") { + return "kimi-coding"; + } + if (normalized === "bedrock" || normalized === "aws-bedrock") { + return "amazon-bedrock"; + } + // Backward compatibility for older provider naming. + if (normalized === "bytedance" || normalized === "doubao") { + return "volcengine"; + } + return normalized; +} + +/** Normalize provider ID for auth lookup. Coding-plan variants share auth with base. */ +export function normalizeProviderIdForAuth(provider: string): string { + const normalized = normalizeProviderId(provider); + if (normalized === "volcengine-plan") { + return "volcengine"; + } + if (normalized === "byteplus-plan") { + return "byteplus"; + } + return normalized; +} + +export function findNormalizedProviderValue( + entries: Record | undefined, + provider: string, +): T | undefined { + if (!entries) { + return undefined; + } + const providerKey = normalizeProviderId(provider); + for (const [key, value] of Object.entries(entries)) { + if (normalizeProviderId(key) === providerKey) { + return value; + } + } + return undefined; +} + +export function findNormalizedProviderKey( + entries: Record | undefined, + provider: string, +): string | undefined { + if (!entries) { + return undefined; + } + const providerKey = normalizeProviderId(provider); + return Object.keys(entries).find((key) => normalizeProviderId(key) === providerKey); +} diff --git a/src/agents/self-hosted-provider-defaults.ts b/src/agents/self-hosted-provider-defaults.ts new file mode 100644 index 00000000000..da9dcc4b1d6 --- /dev/null +++ b/src/agents/self-hosted-provider-defaults.ts @@ -0,0 +1,8 @@ +export const SELF_HOSTED_DEFAULT_CONTEXT_WINDOW = 128000; +export const SELF_HOSTED_DEFAULT_MAX_TOKENS = 8192; +export const SELF_HOSTED_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; diff --git a/src/agents/sglang-defaults.ts b/src/agents/sglang-defaults.ts new file mode 100644 index 00000000000..d91355a8257 --- /dev/null +++ b/src/agents/sglang-defaults.ts @@ -0,0 +1,4 @@ +export const SGLANG_DEFAULT_BASE_URL = "http://127.0.0.1:30000/v1"; +export const SGLANG_PROVIDER_LABEL = "SGLang"; +export const SGLANG_DEFAULT_API_KEY_ENV_VAR = "SGLANG_API_KEY"; +export const SGLANG_MODEL_PLACEHOLDER = "Qwen/Qwen3-8B"; diff --git a/src/agents/skills/compact-format.test.ts b/src/agents/skills/compact-format.test.ts new file mode 100644 index 00000000000..20f3b8a256e --- /dev/null +++ b/src/agents/skills/compact-format.test.ts @@ -0,0 +1,230 @@ +import os from "node:os"; +import { formatSkillsForPrompt, type Skill } from "@mariozechner/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import type { SkillEntry } from "./types.js"; +import { + formatSkillsCompact, + buildWorkspaceSkillsPrompt, + buildWorkspaceSkillSnapshot, +} from "./workspace.js"; + +function makeSkill(name: string, desc = "A skill", filePath = `/skills/${name}/SKILL.md`): Skill { + return { + name, + description: desc, + filePath, + baseDir: `/skills/${name}`, + source: "workspace", + disableModelInvocation: false, + }; +} + +function makeEntry(skill: Skill): SkillEntry { + return { skill, frontmatter: {} }; +} + +function buildPrompt( + skills: Skill[], + limits: { maxChars?: number; maxCount?: number } = {}, +): string { + return buildWorkspaceSkillsPrompt("/fake", { + entries: skills.map(makeEntry), + config: { + skills: { + limits: { + ...(limits.maxChars !== undefined && { maxSkillsPromptChars: limits.maxChars }), + ...(limits.maxCount !== undefined && { maxSkillsInPrompt: limits.maxCount }), + }, + }, + } as any, + }); +} + +describe("formatSkillsCompact", () => { + it("returns empty string for no skills", () => { + expect(formatSkillsCompact([])).toBe(""); + }); + + it("omits description, keeps name and location", () => { + const out = formatSkillsCompact([makeSkill("weather", "Get weather data")]); + expect(out).toContain("weather"); + expect(out).toContain("/skills/weather/SKILL.md"); + expect(out).not.toContain("Get weather data"); + expect(out).not.toContain(""); + }); + + it("filters out disableModelInvocation skills", () => { + const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true }; + const out = formatSkillsCompact([makeSkill("visible"), hidden]); + expect(out).toContain("visible"); + expect(out).not.toContain("hidden"); + }); + + it("escapes XML special characters", () => { + const out = formatSkillsCompact([makeSkill("a { + const skills = Array.from({ length: 50 }, (_, i) => + makeSkill(`skill-${i}`, "A moderately long description that takes up space in the prompt"), + ); + const compact = formatSkillsCompact(skills); + expect(compact.length).toBeLessThan(6000); + }); +}); + +describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => { + it("tier 1: uses full format when under budget", () => { + const skills = [makeSkill("weather", "Get weather data")]; + const prompt = buildPrompt(skills, { maxChars: 50_000 }); + expect(prompt).toContain(""); + expect(prompt).toContain("Get weather data"); + expect(prompt).not.toContain("⚠️"); + }); + + it("tier 2: compact when full exceeds budget but compact fits", () => { + const skills = Array.from({ length: 20 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200))); + const fullLen = formatSkillsForPrompt(skills).length; + const compactLen = formatSkillsCompact(skills).length; + const budget = Math.floor((fullLen + compactLen) / 2); + // Verify preconditions: full exceeds budget, compact fits within overhead-adjusted budget + expect(fullLen).toBeGreaterThan(budget); + expect(compactLen + 150).toBeLessThan(budget); + const prompt = buildPrompt(skills, { maxChars: budget }); + expect(prompt).not.toContain(""); + // All skills preserved — distinct message, no "included X of Y" + expect(prompt).toContain("compact format (descriptions omitted)"); + expect(prompt).not.toContain("included"); + expect(prompt).toContain("skill-0"); + expect(prompt).toContain("skill-19"); + }); + + it("tier 3: compact + binary search when compact also exceeds budget", () => { + const skills = Array.from({ length: 100 }, (_, i) => makeSkill(`skill-${i}`, "description")); + const prompt = buildPrompt(skills, { maxChars: 2000 }); + expect(prompt).toContain("compact format, descriptions omitted"); + expect(prompt).not.toContain(""); + expect(prompt).toContain("skill-0"); + const match = prompt.match(/included (\d+) of (\d+)/); + expect(match).toBeTruthy(); + expect(Number(match![1])).toBeLessThan(Number(match![2])); + expect(Number(match![1])).toBeGreaterThan(0); + }); + + it("compact preserves all skills where full format would drop some", () => { + const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200))); + const compactLen = formatSkillsCompact(skills).length; + const budget = compactLen + 250; + // Verify precondition: full format must not fit so tier 2 is actually exercised + expect(formatSkillsForPrompt(skills).length).toBeGreaterThan(budget); + const prompt = buildPrompt(skills, { maxChars: budget }); + // All 50 fit in compact — no truncation, just compact notice + expect(prompt).toContain("compact format"); + expect(prompt).not.toContain("included"); + expect(prompt).toContain("skill-0"); + expect(prompt).toContain("skill-49"); + }); + + it("count truncation + compact: shows included X of Y with compact note", () => { + // 30 skills but maxCount=10, and full format of 10 exceeds budget + const skills = Array.from({ length: 30 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200))); + const tenSkills = skills.slice(0, 10); + const fullLen = formatSkillsForPrompt(tenSkills).length; + const compactLen = formatSkillsCompact(tenSkills).length; + const budget = compactLen + 200; + // Verify precondition: full format of 10 skills exceeds budget + expect(fullLen).toBeGreaterThan(budget); + const prompt = buildPrompt(skills, { maxChars: budget, maxCount: 10 }); + // Count-truncated (30→10) AND compact (full format of 10 exceeds budget) + expect(prompt).toContain("included 10 of 30"); + expect(prompt).toContain("compact format, descriptions omitted"); + expect(prompt).not.toContain(""); + }); + + it("extreme budget: even a single compact skill overflows", () => { + const skills = [makeSkill("only-one", "desc")]; + // Budget so small that even one compact skill can't fit + const prompt = buildPrompt(skills, { maxChars: 10 }); + expect(prompt).not.toContain("only-one"); + const match = prompt.match(/included (\d+) of (\d+)/); + expect(match).toBeTruthy(); + expect(Number(match![1])).toBe(0); + }); + + it("count truncation only: shows included X of Y without compact note", () => { + const skills = Array.from({ length: 20 }, (_, i) => makeSkill(`skill-${i}`, "short")); + const prompt = buildPrompt(skills, { maxChars: 50_000, maxCount: 5 }); + expect(prompt).toContain("included 5 of 20"); + expect(prompt).not.toContain("compact"); + expect(prompt).toContain(""); + }); + + it("compact budget reserves space for the warning line", () => { + // Build skills whose compact output exactly equals the char budget. + // Without overhead reservation the compact block would fit, but the + // warning line prepended by the caller would push the total over budget. + const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`s-${i}`, "A".repeat(200))); + const compactLen = formatSkillsCompact(skills).length; + // Set budget = compactLen + 50 — less than the 150-char overhead reserve. + // The function should NOT choose compact-only because the warning wouldn't fit. + const prompt = buildPrompt(skills, { maxChars: compactLen + 50 }); + // Should fall through to compact + binary search (some skills dropped) + expect(prompt).toContain("included"); + expect(prompt).not.toContain(""); + }); + + it("budget check uses compacted home-dir paths, not canonical paths", () => { + // Skills with home-dir prefix get compacted (e.g. /home/user/... → ~/...). + // Budget check must use the compacted length, not the longer canonical path. + // If it used canonical paths, it would overestimate and potentially drop + // skills that actually fit after compaction. + const home = os.homedir(); + const skills = Array.from({ length: 30 }, (_, i) => + makeSkill( + `skill-${i}`, + "A".repeat(200), + `${home}/.openclaw/workspace/skills/skill-${i}/SKILL.md`, + ), + ); + // Compute compacted lengths (what the prompt will actually contain) + const compactedSkills = skills.map((s) => ({ + ...s, + filePath: s.filePath.replace(home, "~"), + })); + const compactedCompactLen = formatSkillsCompact(compactedSkills).length; + const canonicalCompactLen = formatSkillsCompact(skills).length; + // Sanity: canonical paths are longer than compacted paths + expect(canonicalCompactLen).toBeGreaterThan(compactedCompactLen); + // Set budget between compacted and canonical lengths — only fits if + // budget check uses compacted paths (correct) not canonical (wrong). + const budget = Math.floor((compactedCompactLen + canonicalCompactLen) / 2) + 150; + const prompt = buildPrompt(skills, { maxChars: budget }); + // All 30 skills should be preserved in compact form (tier 2, no dropping) + expect(prompt).toContain("skill-0"); + expect(prompt).toContain("skill-29"); + expect(prompt).not.toContain("included"); + expect(prompt).toContain("compact format"); + // Verify paths in output are compacted + expect(prompt).toContain("~/"); + expect(prompt).not.toContain(home); + }); + + it("resolvedSkills in snapshot keeps canonical paths, not compacted", () => { + const home = os.homedir(); + const skills = Array.from({ length: 5 }, (_, i) => + makeSkill(`skill-${i}`, "A skill", `${home}/.openclaw/workspace/skills/skill-${i}/SKILL.md`), + ); + const snapshot = buildWorkspaceSkillSnapshot("/fake", { + entries: skills.map(makeEntry), + }); + // Prompt should use compacted paths + expect(snapshot.prompt).toContain("~/"); + // resolvedSkills should preserve canonical (absolute) paths + expect(snapshot.resolvedSkills).toBeDefined(); + for (const skill of snapshot.resolvedSkills!) { + expect(skill.filePath).toContain(home); + expect(skill.filePath).not.toMatch(/^~\//); + } + }); +}); diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index 84c8ea78df3..80624a30139 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -526,10 +526,47 @@ function loadSkillEntries( return skillEntries; } +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Compact skill catalog: name + location only (no description). + * Used as a fallback when the full format exceeds the char budget, + * preserving awareness of all skills before resorting to dropping. + */ +export function formatSkillsCompact(skills: Skill[]): string { + const visible = skills.filter((s) => !s.disableModelInvocation); + if (visible.length === 0) return ""; + const lines = [ + "\n\nThe following skills provide specialized instructions for specific tasks.", + "Use the read tool to load a skill's file when the task matches its name.", + "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", + "", + "", + ]; + for (const skill of visible) { + lines.push(" "); + lines.push(` ${escapeXml(skill.name)}`); + lines.push(` ${escapeXml(skill.filePath)}`); + lines.push(" "); + } + lines.push(""); + return lines.join("\n"); +} + +// Budget reserved for the compact-mode warning line prepended by the caller. +const COMPACT_WARNING_OVERHEAD = 150; + function applySkillsPromptLimits(params: { skills: Skill[]; config?: OpenClawConfig }): { skillsForPrompt: Skill[]; truncated: boolean; - truncatedReason: "count" | "chars" | null; + compact: boolean; } { const limits = resolveSkillsLimits(params.config); const total = params.skills.length; @@ -537,31 +574,41 @@ function applySkillsPromptLimits(params: { skills: Skill[]; config?: OpenClawCon let skillsForPrompt = byCount; let truncated = total > byCount.length; - let truncatedReason: "count" | "chars" | null = truncated ? "count" : null; + let compact = false; - const fits = (skills: Skill[]): boolean => { - const block = formatSkillsForPrompt(skills); - return block.length <= limits.maxSkillsPromptChars; - }; + const fitsFull = (skills: Skill[]): boolean => + formatSkillsForPrompt(skills).length <= limits.maxSkillsPromptChars; - if (!fits(skillsForPrompt)) { - // Binary search the largest prefix that fits in the char budget. - let lo = 0; - let hi = skillsForPrompt.length; - while (lo < hi) { - const mid = Math.ceil((lo + hi) / 2); - if (fits(skillsForPrompt.slice(0, mid))) { - lo = mid; - } else { - hi = mid - 1; + // Reserve space for the warning line the caller prepends in compact mode. + const compactBudget = limits.maxSkillsPromptChars - COMPACT_WARNING_OVERHEAD; + const fitsCompact = (skills: Skill[]): boolean => + formatSkillsCompact(skills).length <= compactBudget; + + if (!fitsFull(skillsForPrompt)) { + // Full format exceeds budget. Try compact (name + location, no description) + // to preserve awareness of all skills before dropping any. + if (fitsCompact(skillsForPrompt)) { + compact = true; + // No skills dropped — only format downgraded. Preserve existing truncated state. + } else { + // Compact still too large — binary search the largest prefix that fits. + compact = true; + let lo = 0; + let hi = skillsForPrompt.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (fitsCompact(skillsForPrompt.slice(0, mid))) { + lo = mid; + } else { + hi = mid - 1; + } } + skillsForPrompt = skillsForPrompt.slice(0, lo); + truncated = true; } - skillsForPrompt = skillsForPrompt.slice(0, lo); - truncated = true; - truncatedReason = "chars"; } - return { skillsForPrompt, truncated, truncatedReason }; + return { skillsForPrompt, truncated, compact }; } export function buildWorkspaceSkillSnapshot( @@ -620,17 +667,24 @@ function resolveWorkspaceSkillPromptState( ); const remoteNote = opts?.eligibility?.remote?.note?.trim(); const resolvedSkills = promptEntries.map((entry) => entry.skill); - const { skillsForPrompt, truncated } = applySkillsPromptLimits({ - skills: resolvedSkills, + // Derive prompt-facing skills with compacted paths (e.g. ~/...) once. + // Budget checks and final render both use this same representation so the + // tier decision is based on the exact strings that end up in the prompt. + // resolvedSkills keeps canonical paths for snapshot / runtime consumers. + const promptSkills = compactSkillPaths(resolvedSkills); + const { skillsForPrompt, truncated, compact } = applySkillsPromptLimits({ + skills: promptSkills, config: opts?.config, }); const truncationNote = truncated - ? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}. Run \`openclaw skills check\` to audit.` - : ""; + ? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}${compact ? " (compact format, descriptions omitted)" : ""}. Run \`openclaw skills check\` to audit.` + : compact + ? `⚠️ Skills catalog using compact format (descriptions omitted). Run \`openclaw skills check\` to audit.` + : ""; const prompt = [ remoteNote, truncationNote, - formatSkillsForPrompt(compactSkillPaths(skillsForPrompt)), + compact ? formatSkillsCompact(skillsForPrompt) : formatSkillsForPrompt(skillsForPrompt), ] .filter(Boolean) .join("\n"); diff --git a/src/agents/tools/browser-tool.actions.ts b/src/agents/tools/browser-tool.actions.ts index dc78557eab2..12cb54e323d 100644 --- a/src/agents/tools/browser-tool.actions.ts +++ b/src/agents/tools/browser-tool.actions.ts @@ -347,7 +347,7 @@ export async function executeActAction(params: { } if (!tabs.length) { throw new Error( - `No Chrome tabs found for profile="${profile}". Make sure Chrome (v144+) is running and has open tabs, then retry.`, + `No browser tabs found for profile="${profile}". Make sure the configured Chromium-based browser (v144+) is running and has open tabs, then retry.`, { cause: err }, ); } diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index c0111ab9977..f16e7e5d969 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -307,7 +307,7 @@ export function createBrowserTool(opts?: { description: [ "Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).", "Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).", - 'For the logged-in user browser on the local host, use profile="user". Chrome (v144+) must be running. Use only when existing logins/cookies matter and the user is present.', + 'For the logged-in user browser on the local host, use profile="user". A supported Chromium-based browser (v144+) must be running. Use only when existing logins/cookies matter and the user is present.', 'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node= or target="node".', "When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).", 'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.', diff --git a/src/agents/vllm-defaults.ts b/src/agents/vllm-defaults.ts new file mode 100644 index 00000000000..3f2498221f0 --- /dev/null +++ b/src/agents/vllm-defaults.ts @@ -0,0 +1,4 @@ +export const VLLM_DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1"; +export const VLLM_PROVIDER_LABEL = "vLLM"; +export const VLLM_DEFAULT_API_KEY_ENV_VAR = "VLLM_API_KEY"; +export const VLLM_MODEL_PLACEHOLDER = "meta-llama/Meta-Llama-3-8B-Instruct"; diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index d9e985c8b31..21a22faf8b2 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { importFreshModule } from "../../../test/helpers/import-fresh.js"; -import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../channels/plugins/contracts/suites.js"; import type { OpenClawConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import type { MsgContext } from "../templating.js"; diff --git a/src/auto-reply/reply/reply-payloads.test.ts b/src/auto-reply/reply/reply-payloads.test.ts index 614fcd37951..8664eec5c72 100644 --- a/src/auto-reply/reply/reply-payloads.test.ts +++ b/src/auto-reply/reply/reply-payloads.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { filterMessagingToolMediaDuplicates, shouldSuppressMessagingToolReplies, @@ -153,4 +155,18 @@ describe("shouldSuppressMessagingToolReplies", () => { }), ).toBe(true); }); + + it("suppresses telegram replies even when the active plugin registry omits telegram", () => { + setActivePluginRegistry(createTestRegistry([])); + + expect( + shouldSuppressMessagingToolReplies({ + messageProvider: "telegram", + originatingTo: "telegram:group:-100123:topic:77", + messagingToolSentTargets: [ + { tool: "message", provider: "telegram", to: "-100123", threadId: "77" }, + ], + }), + ).toBe(true); + }); }); diff --git a/src/browser/chrome-mcp.test.ts b/src/browser/chrome-mcp.test.ts index a77149d7a72..03204cf3b87 100644 --- a/src/browser/chrome-mcp.test.ts +++ b/src/browser/chrome-mcp.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + buildChromeMcpArgs, evaluateChromeMcpScript, listChromeMcpTabs, openChromeMcpTab, @@ -103,6 +104,18 @@ describe("chrome MCP page parsing", () => { ]); }); + it("adds --userDataDir when an explicit Chromium profile path is configured", () => { + expect(buildChromeMcpArgs("/tmp/brave-profile")).toEqual([ + "-y", + "chrome-devtools-mcp@latest", + "--autoConnect", + "--experimentalStructuredContent", + "--experimental-page-id-routing", + "--userDataDir", + "/tmp/brave-profile", + ]); + }); + it("parses new_page text responses and returns the created tab", async () => { const factory: ChromeMcpSessionFactory = async () => createFakeSession(); setChromeMcpSessionFactoryForTest(factory); @@ -250,6 +263,33 @@ describe("chrome MCP page parsing", () => { expect(tabs).toHaveLength(2); }); + it("creates a fresh session when userDataDir changes for the same profile", async () => { + const createdSessions: ChromeMcpSession[] = []; + const closeMocks: Array> = []; + const factoryCalls: Array<{ profileName: string; userDataDir?: string }> = []; + const factory: ChromeMcpSessionFactory = async (profileName, userDataDir) => { + factoryCalls.push({ profileName, userDataDir }); + const session = createFakeSession(); + const closeMock = vi.fn().mockResolvedValue(undefined); + session.client.close = closeMock as typeof session.client.close; + createdSessions.push(session); + closeMocks.push(closeMock); + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + await listChromeMcpTabs("chrome-live", "/tmp/brave-a"); + await listChromeMcpTabs("chrome-live", "/tmp/brave-b"); + + expect(factoryCalls).toEqual([ + { profileName: "chrome-live", userDataDir: "/tmp/brave-a" }, + { profileName: "chrome-live", userDataDir: "/tmp/brave-b" }, + ]); + expect(createdSessions).toHaveLength(2); + expect(closeMocks[0]).toHaveBeenCalledTimes(1); + expect(closeMocks[1]).not.toHaveBeenCalled(); + }); + it("clears failed pending sessions so the next call can retry", async () => { let factoryCalls = 0; const factory: ChromeMcpSessionFactory = async () => { diff --git a/src/browser/chrome-mcp.ts b/src/browser/chrome-mcp.ts index a673feb2c27..bc724d2eaea 100644 --- a/src/browser/chrome-mcp.ts +++ b/src/browser/chrome-mcp.ts @@ -26,7 +26,10 @@ type ChromeMcpSession = { ready: Promise; }; -type ChromeMcpSessionFactory = (profileName: string) => Promise; +type ChromeMcpSessionFactory = ( + profileName: string, + userDataDir?: string, +) => Promise; const DEFAULT_CHROME_MCP_COMMAND = "npx"; const DEFAULT_CHROME_MCP_ARGS = [ @@ -168,10 +171,62 @@ function extractJsonMessage(result: ChromeMcpToolResult): unknown { return null; } -async function createRealSession(profileName: string): Promise { +function normalizeChromeMcpUserDataDir(userDataDir?: string): string | undefined { + const trimmed = userDataDir?.trim(); + return trimmed ? trimmed : undefined; +} + +function buildChromeMcpSessionCacheKey(profileName: string, userDataDir?: string): string { + return JSON.stringify([profileName, normalizeChromeMcpUserDataDir(userDataDir) ?? ""]); +} + +function cacheKeyMatchesProfileName(cacheKey: string, profileName: string): boolean { + try { + const parsed = JSON.parse(cacheKey); + return Array.isArray(parsed) && parsed[0] === profileName; + } catch { + return false; + } +} + +async function closeChromeMcpSessionsForProfile( + profileName: string, + keepKey?: string, +): Promise { + let closed = false; + + for (const key of Array.from(pendingSessions.keys())) { + if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) { + pendingSessions.delete(key); + closed = true; + } + } + + for (const [key, session] of Array.from(sessions.entries())) { + if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) { + sessions.delete(key); + closed = true; + await session.client.close().catch(() => {}); + } + } + + return closed; +} + +export function buildChromeMcpArgs(userDataDir?: string): string[] { + const normalizedUserDataDir = normalizeChromeMcpUserDataDir(userDataDir); + return normalizedUserDataDir + ? [...DEFAULT_CHROME_MCP_ARGS, "--userDataDir", normalizedUserDataDir] + : [...DEFAULT_CHROME_MCP_ARGS]; +} + +async function createRealSession( + profileName: string, + userDataDir?: string, +): Promise { const transport = new StdioClientTransport({ command: DEFAULT_CHROME_MCP_COMMAND, - args: DEFAULT_CHROME_MCP_ARGS, + args: buildChromeMcpArgs(userDataDir), stderr: "pipe", }); const client = new Client( @@ -191,9 +246,12 @@ async function createRealSession(profileName: string): Promise } } catch (err) { await client.close().catch(() => {}); + const targetLabel = userDataDir + ? `the configured Chromium user data dir (${userDataDir})` + : "Google Chrome's default profile"; throw new BrowserProfileUnavailableError( `Chrome MCP existing-session attach failed for profile "${profileName}". ` + - `Make sure Chrome (v144+) is running. ` + + `Make sure ${targetLabel} is running locally with remote debugging enabled. ` + `Details: ${String(err)}`, ); } @@ -206,27 +264,34 @@ async function createRealSession(profileName: string): Promise }; } -async function getSession(profileName: string): Promise { - let session = sessions.get(profileName); +async function getSession(profileName: string, userDataDir?: string): Promise { + const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); + await closeChromeMcpSessionsForProfile(profileName, cacheKey); + + let session = sessions.get(cacheKey); if (session && session.transport.pid === null) { - sessions.delete(profileName); + sessions.delete(cacheKey); session = undefined; } if (!session) { - let pending = pendingSessions.get(profileName); + let pending = pendingSessions.get(cacheKey); if (!pending) { pending = (async () => { - const created = await (sessionFactory ?? createRealSession)(profileName); - sessions.set(profileName, created); + const created = await (sessionFactory ?? createRealSession)(profileName, userDataDir); + if (pendingSessions.get(cacheKey) === pending) { + sessions.set(cacheKey, created); + } else { + await created.client.close().catch(() => {}); + } return created; })(); - pendingSessions.set(profileName, pending); + pendingSessions.set(cacheKey, pending); } try { session = await pending; } finally { - if (pendingSessions.get(profileName) === pending) { - pendingSessions.delete(profileName); + if (pendingSessions.get(cacheKey) === pending) { + pendingSessions.delete(cacheKey); } } } @@ -234,9 +299,9 @@ async function getSession(profileName: string): Promise { await session.ready; return session; } catch (err) { - const current = sessions.get(profileName); + const current = sessions.get(cacheKey); if (current?.transport === session.transport) { - sessions.delete(profileName); + sessions.delete(cacheKey); } throw err; } @@ -244,10 +309,12 @@ async function getSession(profileName: string): Promise { async function callTool( profileName: string, + userDataDir: string | undefined, name: string, args: Record = {}, ): Promise { - const session = await getSession(profileName); + const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir); + const session = await getSession(profileName, userDataDir); let result: ChromeMcpToolResult; try { result = (await session.client.callTool({ @@ -256,7 +323,7 @@ async function callTool( })) as ChromeMcpToolResult; } catch (err) { // Transport/connection error — tear down session so it reconnects on next call - sessions.delete(profileName); + sessions.delete(cacheKey); await session.client.close().catch(() => {}); throw err; } @@ -278,8 +345,12 @@ async function withTempFile(fn: (filePath: string) => Promise): Promise } } -async function findPageById(profileName: string, pageId: number): Promise { - const pages = await listChromeMcpPages(profileName); +async function findPageById( + profileName: string, + pageId: number, + userDataDir?: string, +): Promise { + const pages = await listChromeMcpPages(profileName, userDataDir); const page = pages.find((entry) => entry.id === pageId); if (!page) { throw new BrowserTabNotFoundError(); @@ -287,43 +358,54 @@ async function findPageById(profileName: string, pageId: number): Promise { - await getSession(profileName); +export async function ensureChromeMcpAvailable( + profileName: string, + userDataDir?: string, +): Promise { + await getSession(profileName, userDataDir); } export function getChromeMcpPid(profileName: string): number | null { - return sessions.get(profileName)?.transport.pid ?? null; + for (const [key, session] of sessions.entries()) { + if (cacheKeyMatchesProfileName(key, profileName)) { + return session.transport.pid ?? null; + } + } + return null; } export async function closeChromeMcpSession(profileName: string): Promise { - pendingSessions.delete(profileName); - const session = sessions.get(profileName); - if (!session) { - return false; - } - sessions.delete(profileName); - await session.client.close().catch(() => {}); - return true; + return await closeChromeMcpSessionsForProfile(profileName); } export async function stopAllChromeMcpSessions(): Promise { - const names = [...sessions.keys()]; + const names = [...new Set([...sessions.keys()].map((key) => JSON.parse(key)[0] as string))]; for (const name of names) { await closeChromeMcpSession(name).catch(() => {}); } } -export async function listChromeMcpPages(profileName: string): Promise { - const result = await callTool(profileName, "list_pages"); +export async function listChromeMcpPages( + profileName: string, + userDataDir?: string, +): Promise { + const result = await callTool(profileName, userDataDir, "list_pages"); return extractStructuredPages(result); } -export async function listChromeMcpTabs(profileName: string): Promise { - return toBrowserTabs(await listChromeMcpPages(profileName)); +export async function listChromeMcpTabs( + profileName: string, + userDataDir?: string, +): Promise { + return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir)); } -export async function openChromeMcpTab(profileName: string, url: string): Promise { - const result = await callTool(profileName, "new_page", { url }); +export async function openChromeMcpTab( + profileName: string, + url: string, + userDataDir?: string, +): Promise { + const result = await callTool(profileName, userDataDir, "new_page", { url }); const pages = extractStructuredPages(result); const chosen = pages.find((page) => page.selected) ?? pages.at(-1); if (!chosen) { @@ -337,38 +419,52 @@ export async function openChromeMcpTab(profileName: string, url: string): Promis }; } -export async function focusChromeMcpTab(profileName: string, targetId: string): Promise { - await callTool(profileName, "select_page", { +export async function focusChromeMcpTab( + profileName: string, + targetId: string, + userDataDir?: string, +): Promise { + await callTool(profileName, userDataDir, "select_page", { pageId: parsePageId(targetId), bringToFront: true, }); } -export async function closeChromeMcpTab(profileName: string, targetId: string): Promise { - await callTool(profileName, "close_page", { pageId: parsePageId(targetId) }); +export async function closeChromeMcpTab( + profileName: string, + targetId: string, + userDataDir?: string, +): Promise { + await callTool(profileName, userDataDir, "close_page", { pageId: parsePageId(targetId) }); } export async function navigateChromeMcpPage(params: { profileName: string; + userDataDir?: string; targetId: string; url: string; timeoutMs?: number; }): Promise<{ url: string }> { - await callTool(params.profileName, "navigate_page", { + await callTool(params.profileName, params.userDataDir, "navigate_page", { pageId: parsePageId(params.targetId), type: "url", url: params.url, ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), }); - const page = await findPageById(params.profileName, parsePageId(params.targetId)); + const page = await findPageById( + params.profileName, + parsePageId(params.targetId), + params.userDataDir, + ); return { url: page.url ?? params.url }; } export async function takeChromeMcpSnapshot(params: { profileName: string; + userDataDir?: string; targetId: string; }): Promise { - const result = await callTool(params.profileName, "take_snapshot", { + const result = await callTool(params.profileName, params.userDataDir, "take_snapshot", { pageId: parsePageId(params.targetId), }); return extractSnapshot(result); @@ -376,13 +472,14 @@ export async function takeChromeMcpSnapshot(params: { export async function takeChromeMcpScreenshot(params: { profileName: string; + userDataDir?: string; targetId: string; uid?: string; fullPage?: boolean; format?: "png" | "jpeg"; }): Promise { return await withTempFile(async (filePath) => { - await callTool(params.profileName, "take_screenshot", { + await callTool(params.profileName, params.userDataDir, "take_screenshot", { pageId: parsePageId(params.targetId), filePath, format: params.format ?? "png", @@ -395,11 +492,12 @@ export async function takeChromeMcpScreenshot(params: { export async function clickChromeMcpElement(params: { profileName: string; + userDataDir?: string; targetId: string; uid: string; doubleClick?: boolean; }): Promise { - await callTool(params.profileName, "click", { + await callTool(params.profileName, params.userDataDir, "click", { pageId: parsePageId(params.targetId), uid: params.uid, ...(params.doubleClick ? { dblClick: true } : {}), @@ -408,11 +506,12 @@ export async function clickChromeMcpElement(params: { export async function fillChromeMcpElement(params: { profileName: string; + userDataDir?: string; targetId: string; uid: string; value: string; }): Promise { - await callTool(params.profileName, "fill", { + await callTool(params.profileName, params.userDataDir, "fill", { pageId: parsePageId(params.targetId), uid: params.uid, value: params.value, @@ -421,10 +520,11 @@ export async function fillChromeMcpElement(params: { export async function fillChromeMcpForm(params: { profileName: string; + userDataDir?: string; targetId: string; elements: Array<{ uid: string; value: string }>; }): Promise { - await callTool(params.profileName, "fill_form", { + await callTool(params.profileName, params.userDataDir, "fill_form", { pageId: parsePageId(params.targetId), elements: params.elements, }); @@ -432,10 +532,11 @@ export async function fillChromeMcpForm(params: { export async function hoverChromeMcpElement(params: { profileName: string; + userDataDir?: string; targetId: string; uid: string; }): Promise { - await callTool(params.profileName, "hover", { + await callTool(params.profileName, params.userDataDir, "hover", { pageId: parsePageId(params.targetId), uid: params.uid, }); @@ -443,11 +544,12 @@ export async function hoverChromeMcpElement(params: { export async function dragChromeMcpElement(params: { profileName: string; + userDataDir?: string; targetId: string; fromUid: string; toUid: string; }): Promise { - await callTool(params.profileName, "drag", { + await callTool(params.profileName, params.userDataDir, "drag", { pageId: parsePageId(params.targetId), from_uid: params.fromUid, to_uid: params.toUid, @@ -456,11 +558,12 @@ export async function dragChromeMcpElement(params: { export async function uploadChromeMcpFile(params: { profileName: string; + userDataDir?: string; targetId: string; uid: string; filePath: string; }): Promise { - await callTool(params.profileName, "upload_file", { + await callTool(params.profileName, params.userDataDir, "upload_file", { pageId: parsePageId(params.targetId), uid: params.uid, filePath: params.filePath, @@ -469,10 +572,11 @@ export async function uploadChromeMcpFile(params: { export async function pressChromeMcpKey(params: { profileName: string; + userDataDir?: string; targetId: string; key: string; }): Promise { - await callTool(params.profileName, "press_key", { + await callTool(params.profileName, params.userDataDir, "press_key", { pageId: parsePageId(params.targetId), key: params.key, }); @@ -480,11 +584,12 @@ export async function pressChromeMcpKey(params: { export async function resizeChromeMcpPage(params: { profileName: string; + userDataDir?: string; targetId: string; width: number; height: number; }): Promise { - await callTool(params.profileName, "resize_page", { + await callTool(params.profileName, params.userDataDir, "resize_page", { pageId: parsePageId(params.targetId), width: params.width, height: params.height, @@ -493,11 +598,12 @@ export async function resizeChromeMcpPage(params: { export async function handleChromeMcpDialog(params: { profileName: string; + userDataDir?: string; targetId: string; action: "accept" | "dismiss"; promptText?: string; }): Promise { - await callTool(params.profileName, "handle_dialog", { + await callTool(params.profileName, params.userDataDir, "handle_dialog", { pageId: parsePageId(params.targetId), action: params.action, ...(params.promptText ? { promptText: params.promptText } : {}), @@ -506,11 +612,12 @@ export async function handleChromeMcpDialog(params: { export async function evaluateChromeMcpScript(params: { profileName: string; + userDataDir?: string; targetId: string; fn: string; args?: string[]; }): Promise { - const result = await callTool(params.profileName, "evaluate_script", { + const result = await callTool(params.profileName, params.userDataDir, "evaluate_script", { pageId: parsePageId(params.targetId), function: params.fn, ...(params.args?.length ? { args: params.args } : {}), @@ -520,11 +627,12 @@ export async function evaluateChromeMcpScript(params: { export async function waitForChromeMcpText(params: { profileName: string; + userDataDir?: string; targetId: string; text: string[]; timeoutMs?: number; }): Promise { - await callTool(params.profileName, "wait_for", { + await callTool(params.profileName, params.userDataDir, "wait_for", { pageId: parsePageId(params.targetId), text: params.text, ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), diff --git a/src/browser/client.ts b/src/browser/client.ts index 7791b4405be..d7d8690147f 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -162,6 +162,7 @@ export type BrowserCreateProfileResult = { transport?: BrowserTransport; cdpPort: number | null; cdpUrl: string | null; + userDataDir: string | null; color: string; isRemote: boolean; }; @@ -172,6 +173,7 @@ export async function browserCreateProfile( name: string; color?: string; cdpUrl?: string; + userDataDir?: string; driver?: "openclaw" | "existing-session"; }, ): Promise { @@ -184,6 +186,7 @@ export async function browserCreateProfile( name: opts.name, color: opts.color, cdpUrl: opts.cdpUrl, + userDataDir: opts.userDataDir, driver: opts.driver, }), timeoutMs: 10000, diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index 7f80c4389a1..8ca609f13b6 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; +import { resolveUserPath } from "../utils.js"; import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; @@ -26,6 +27,7 @@ describe("browser config", () => { expect(user?.driver).toBe("existing-session"); expect(user?.cdpPort).toBe(0); expect(user?.cdpUrl).toBe(""); + expect(user?.userDataDir).toBeUndefined(); // chrome-relay is no longer auto-created expect(resolveProfile(resolved, "chrome-relay")).toBe(null); expect(resolved.remoteCdpTimeoutMs).toBe(1500); @@ -275,9 +277,29 @@ describe("browser config", () => { expect(profile?.cdpPort).toBe(0); expect(profile?.cdpUrl).toBe(""); expect(profile?.cdpIsLoopback).toBe(true); + expect(profile?.userDataDir).toBeUndefined(); expect(profile?.color).toBe("#00AA00"); }); + it("expands tilde-prefixed userDataDir for existing-session profiles", () => { + const resolved = resolveBrowserConfig({ + profiles: { + brave: { + driver: "existing-session", + attachOnly: true, + userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, + }, + }); + + const profile = resolveProfile(resolved, "brave"); + expect(profile?.driver).toBe("existing-session"); + expect(profile?.userDataDir).toBe( + resolveUserPath("~/Library/Application Support/BraveSoftware/Brave-Browser"), + ); + }); + it("sets usesChromeMcp only for existing-session profiles", () => { const resolved = resolveBrowserConfig({ profiles: { diff --git a/src/browser/config.ts b/src/browser/config.ts index 64fffce865c..a5bc131766a 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -7,6 +7,7 @@ import { } from "../config/port-defaults.js"; import { isLoopbackHost } from "../gateway/net.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { resolveUserPath } from "../utils.js"; import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_ENABLED, @@ -44,6 +45,7 @@ export type ResolvedBrowserProfile = { cdpUrl: string; cdpHost: string; cdpIsLoopback: boolean; + userDataDir?: string; color: string; driver: "openclaw" | "existing-session"; attachOnly: boolean; @@ -328,6 +330,7 @@ export function resolveProfile( cdpUrl: "", cdpHost: "", cdpIsLoopback: true, + userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined, color: profile.color, driver, attachOnly: true, diff --git a/src/browser/profiles-service.test.ts b/src/browser/profiles-service.test.ts index b726ad3fbdb..e36ae0ce695 100644 --- a/src/browser/profiles-service.test.ts +++ b/src/browser/profiles-service.test.ts @@ -150,6 +150,7 @@ describe("BrowserProfilesService", () => { expect(result.transport).toBe("chrome-mcp"); expect(result.cdpPort).toBeNull(); expect(result.cdpUrl).toBeNull(); + expect(result.userDataDir).toBeNull(); expect(result.isRemote).toBe(false); expect(state.resolved.profiles["chrome-live"]).toEqual({ driver: "existing-session", @@ -186,6 +187,51 @@ describe("BrowserProfilesService", () => { ).rejects.toThrow(/does not accept cdpUrl/i); }); + it("creates existing-session profiles with an explicit userDataDir", async () => { + const resolved = resolveBrowserConfig({}); + const { ctx, state } = createCtx(resolved); + vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + + const tempDir = fs.mkdtempSync(path.join("/tmp", "openclaw-profile-")); + const userDataDir = path.join(tempDir, "BraveSoftware", "Brave-Browser"); + fs.mkdirSync(userDataDir, { recursive: true }); + + const service = createBrowserProfilesService(ctx); + const result = await service.createProfile({ + name: "brave-live", + driver: "existing-session", + userDataDir, + }); + + expect(result.transport).toBe("chrome-mcp"); + expect(result.userDataDir).toBe(userDataDir); + expect(state.resolved.profiles["brave-live"]).toEqual({ + driver: "existing-session", + attachOnly: true, + userDataDir, + color: expect.any(String), + }); + }); + + it("rejects userDataDir for non-existing-session profiles", async () => { + const resolved = resolveBrowserConfig({}); + const { ctx } = createCtx(resolved); + vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + + const tempDir = fs.mkdtempSync(path.join("/tmp", "openclaw-profile-")); + const userDataDir = path.join(tempDir, "BraveSoftware", "Brave-Browser"); + fs.mkdirSync(userDataDir, { recursive: true }); + + const service = createBrowserProfilesService(ctx); + + await expect( + service.createProfile({ + name: "brave-live", + userDataDir, + }), + ).rejects.toThrow(/driver=existing-session is required/i); + }); + it("deletes remote profiles without stopping or removing local data", async () => { const resolved = resolveBrowserConfig({ profiles: { diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index af747015e45..ea1f3b674c6 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; +import { resolveUserPath } from "../utils.js"; import { resolveOpenClawUserDataDir } from "./chrome.js"; import { parseHttpUrl, resolveProfile } from "./config.js"; import { @@ -26,6 +27,7 @@ export type CreateProfileParams = { name: string; color?: string; cdpUrl?: string; + userDataDir?: string; driver?: "openclaw" | "existing-session"; }; @@ -35,6 +37,7 @@ export type CreateProfileResult = { transport: "cdp" | "chrome-mcp"; cdpPort: number | null; cdpUrl: string | null; + userDataDir: string | null; color: string; isRemote: boolean; }; @@ -79,6 +82,8 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { const createProfile = async (params: CreateProfileParams): Promise => { const name = params.name.trim(); const rawCdpUrl = params.cdpUrl?.trim() || undefined; + const rawUserDataDir = params.userDataDir?.trim() || undefined; + const normalizedUserDataDir = rawUserDataDir ? resolveUserPath(rawUserDataDir) : undefined; const driver = params.driver === "existing-session" ? "existing-session" : undefined; if (!isValidProfileName(name)) { @@ -104,6 +109,17 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { params.color && HEX_COLOR_RE.test(params.color) ? params.color : allocateColor(usedColors); let profileConfig: BrowserProfileConfig; + if (normalizedUserDataDir && driver !== "existing-session") { + throw new BrowserValidationError( + "driver=existing-session is required when userDataDir is provided", + ); + } + if (normalizedUserDataDir && !fs.existsSync(normalizedUserDataDir)) { + throw new BrowserValidationError( + `browser user data directory not found: ${normalizedUserDataDir}`, + ); + } + if (rawCdpUrl) { let parsed: ReturnType; try { @@ -127,6 +143,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { profileConfig = { driver, attachOnly: true, + ...(normalizedUserDataDir ? { userDataDir: normalizedUserDataDir } : {}), color: profileColor, }; } else { @@ -170,6 +187,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp", cdpPort: capabilities.usesChromeMcp ? null : resolved.cdpPort, cdpUrl: capabilities.usesChromeMcp ? null : resolved.cdpUrl, + userDataDir: resolved.userDataDir ?? null, color: resolved.color, isRemote: !resolved.cdpIsLoopback, }; diff --git a/src/browser/resolved-config-refresh.ts b/src/browser/resolved-config-refresh.ts index 999a7ca1229..1d20eecec94 100644 --- a/src/browser/resolved-config-refresh.ts +++ b/src/browser/resolved-config-refresh.ts @@ -22,6 +22,9 @@ function changedProfileInvariants( if (current.cdpIsLoopback !== next.cdpIsLoopback) { changed.push("cdpIsLoopback"); } + if ((current.userDataDir ?? "") !== (next.userDataDir ?? "")) { + changed.push("userDataDir"); + } return changed; } diff --git a/src/browser/routes/agent.act.hooks.ts b/src/browser/routes/agent.act.hooks.ts index a141a9cbe5a..a55e2f9b21e 100644 --- a/src/browser/routes/agent.act.hooks.ts +++ b/src/browser/routes/agent.act.hooks.ts @@ -65,6 +65,7 @@ export function registerBrowserAgentActHookRoutes( } await uploadChromeMcpFile({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid, filePath: resolvedPaths[0] ?? "", @@ -134,6 +135,7 @@ export function registerBrowserAgentActHookRoutes( } await evaluateChromeMcpScript({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, fn: `() => { const state = (window.__openclawDialogHook ??= {}); diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts index 1b444d1b963..af0d8e40794 100644 --- a/src/browser/routes/agent.act.ts +++ b/src/browser/routes/agent.act.ts @@ -78,6 +78,7 @@ function buildExistingSessionWaitPredicate(params: { async function waitForExistingSessionCondition(params: { profileName: string; + userDataDir?: string; targetId: string; timeMs?: number; text?: string; @@ -103,6 +104,7 @@ async function waitForExistingSessionCondition(params: { ready = Boolean( await evaluateChromeMcpScript({ profileName: params.profileName, + userDataDir: params.userDataDir, targetId: params.targetId, fn: `async () => ${predicate}`, }), @@ -111,6 +113,7 @@ async function waitForExistingSessionCondition(params: { if (ready && params.url) { const currentUrl = await evaluateChromeMcpScript({ profileName: params.profileName, + userDataDir: params.userDataDir, targetId: params.targetId, fn: "() => window.location.href", }); @@ -520,6 +523,7 @@ export function registerBrowserAgentActRoutes( } await clickChromeMcpElement({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid: ref!, doubleClick, @@ -586,6 +590,7 @@ export function registerBrowserAgentActRoutes( } await fillChromeMcpElement({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid: ref!, value: text, @@ -593,6 +598,7 @@ export function registerBrowserAgentActRoutes( if (submit) { await pressChromeMcpKey({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, key: "Enter", }); @@ -632,7 +638,12 @@ export function registerBrowserAgentActRoutes( if (delayMs) { return jsonError(res, 501, "existing-session press does not support delayMs."); } - await pressChromeMcpKey({ profileName, targetId: tab.targetId, key }); + await pressChromeMcpKey({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + key, + }); return res.json({ ok: true, targetId: tab.targetId }); } const pw = await requirePwAi(res, `act:${kind}`); @@ -669,7 +680,12 @@ export function registerBrowserAgentActRoutes( "existing-session hover does not support timeoutMs overrides.", ); } - await hoverChromeMcpElement({ profileName, targetId: tab.targetId, uid: ref! }); + await hoverChromeMcpElement({ + profileName, + userDataDir: profileCtx.profile.userDataDir, + targetId: tab.targetId, + uid: ref!, + }); return res.json({ ok: true, targetId: tab.targetId }); } const pw = await requirePwAi(res, `act:${kind}`); @@ -709,6 +725,7 @@ export function registerBrowserAgentActRoutes( } await evaluateChromeMcpScript({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`, args: [ref!], @@ -764,6 +781,7 @@ export function registerBrowserAgentActRoutes( } await dragChromeMcpElement({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, fromUid: startRef!, toUid: endRef!, @@ -817,6 +835,7 @@ export function registerBrowserAgentActRoutes( } await fillChromeMcpElement({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid: ref!, value: values[0] ?? "", @@ -861,6 +880,7 @@ export function registerBrowserAgentActRoutes( } await fillChromeMcpForm({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, elements: fields.map((field) => ({ uid: field.ref, @@ -890,6 +910,7 @@ export function registerBrowserAgentActRoutes( if (isExistingSession) { await resizeChromeMcpPage({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, width, height, @@ -951,6 +972,7 @@ export function registerBrowserAgentActRoutes( } await waitForExistingSessionCondition({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, timeMs, text, @@ -1001,6 +1023,7 @@ export function registerBrowserAgentActRoutes( } const result = await evaluateChromeMcpScript({ profileName, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, fn, args: ref ? [ref] : undefined, @@ -1036,7 +1059,7 @@ export function registerBrowserAgentActRoutes( } case "close": { if (isExistingSession) { - await closeChromeMcpTab(profileName, tab.targetId); + await closeChromeMcpTab(profileName, tab.targetId, profileCtx.profile.userDataDir); return res.json({ ok: true, targetId: tab.targetId }); } const pw = await requirePwAi(res, `act:${kind}`); @@ -1151,6 +1174,7 @@ export function registerBrowserAgentActRoutes( if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { await evaluateChromeMcpScript({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, args: [ref], fn: `(el) => { diff --git a/src/browser/routes/agent.snapshot.ts b/src/browser/routes/agent.snapshot.ts index 80c11693a11..7cb73049389 100644 --- a/src/browser/routes/agent.snapshot.ts +++ b/src/browser/routes/agent.snapshot.ts @@ -44,10 +44,12 @@ const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay"; async function clearChromeMcpOverlay(params: { profileName: string; + userDataDir?: string; targetId: string; }): Promise { await evaluateChromeMcpScript({ profileName: params.profileName, + userDataDir: params.userDataDir, targetId: params.targetId, fn: `() => { document.querySelectorAll("[${CHROME_MCP_OVERLAY_ATTR}]").forEach((node) => node.remove()); @@ -58,12 +60,14 @@ async function clearChromeMcpOverlay(params: { async function renderChromeMcpLabels(params: { profileName: string; + userDataDir?: string; targetId: string; refs: string[]; }): Promise<{ labels: number; skipped: number }> { const refList = JSON.stringify(params.refs); const result = await evaluateChromeMcpScript({ profileName: params.profileName, + userDataDir: params.userDataDir, targetId: params.targetId, args: params.refs, fn: `(...elements) => { @@ -231,6 +235,7 @@ export function registerBrowserAgentSnapshotRoutes( await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); const result = await navigateChromeMcpPage({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, url, }); @@ -322,6 +327,7 @@ export function registerBrowserAgentSnapshotRoutes( } const buffer = await takeChromeMcpScreenshot({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, uid: ref, fullPage, @@ -406,6 +412,7 @@ export function registerBrowserAgentSnapshotRoutes( } const snapshot = await takeChromeMcpSnapshot({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, }); if (plan.format === "aria") { @@ -430,12 +437,14 @@ export function registerBrowserAgentSnapshotRoutes( const refs = Object.keys(built.refs); const labelResult = await renderChromeMcpLabels({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, refs, }); try { const labeled = await takeChromeMcpScreenshot({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, format: "png", }); @@ -465,6 +474,7 @@ export function registerBrowserAgentSnapshotRoutes( } finally { await clearChromeMcpOverlay({ profileName: profileCtx.profile.name, + userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, }); } diff --git a/src/browser/routes/basic.existing-session.test.ts b/src/browser/routes/basic.existing-session.test.ts index 34bcd9ee00b..b96596c6fbe 100644 --- a/src/browser/routes/basic.existing-session.test.ts +++ b/src/browser/routes/basic.existing-session.test.ts @@ -27,6 +27,7 @@ describe("basic browser routes", () => { driver: "existing-session", cdpPort: 0, cdpUrl: "", + userDataDir: "/tmp/brave-profile", color: "#00AA00", attachOnly: true, }, @@ -66,6 +67,7 @@ describe("basic browser routes", () => { driver: "existing-session", cdpPort: 0, cdpUrl: "", + userDataDir: "/tmp/brave-profile", color: "#00AA00", attachOnly: true, }, @@ -88,6 +90,7 @@ describe("basic browser routes", () => { running: true, cdpPort: null, cdpUrl: null, + userDataDir: "/tmp/brave-profile", pid: 4321, }); }); diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index c4f5db47a59..b781bc62694 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -112,7 +112,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow detectedBrowser, detectedExecutablePath, detectError, - userDataDir: profileState?.running?.userDataDir ?? null, + userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null, color: profileCtx.profile.color, headless: current.resolved.headless, noSandbox: current.resolved.noSandbox, @@ -176,6 +176,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow const name = toStringOrEmpty((req.body as { name?: unknown })?.name); const color = toStringOrEmpty((req.body as { color?: unknown })?.color); const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl); + const userDataDir = toStringOrEmpty((req.body as { userDataDir?: unknown })?.userDataDir); const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver); if (!name) { @@ -197,6 +198,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow name, color: color || undefined, cdpUrl: cdpUrl || undefined, + userDataDir: userDataDir || undefined, driver: driver === "existing-session" ? "existing-session" diff --git a/src/browser/server-context.availability.ts b/src/browser/server-context.availability.ts index d7d33fd0fde..6630c17a4c0 100644 --- a/src/browser/server-context.availability.ts +++ b/src/browser/server-context.availability.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { PROFILE_ATTACH_RETRY_TIMEOUT_MS, PROFILE_POST_RESTART_WS_TIMEOUT_MS, @@ -63,7 +64,7 @@ export function createProfileAvailability({ const isReachable = async (timeoutMs?: number) => { if (capabilities.usesChromeMcp) { // listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required - await listChromeMcpTabs(profile.name); + await listChromeMcpTabs(profile.name, profile.userDataDir); return true; } const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs); @@ -153,7 +154,12 @@ export function createProfileAvailability({ const ensureBrowserAvailable = async (): Promise => { await reconcileProfileRuntime(); if (capabilities.usesChromeMcp) { - await ensureChromeMcpAvailable(profile.name); + if (profile.userDataDir && !fs.existsSync(profile.userDataDir)) { + throw new BrowserProfileUnavailableError( + `Browser user data directory not found for profile "${profile.name}": ${profile.userDataDir}`, + ); + } + await ensureChromeMcpAvailable(profile.name, profile.userDataDir); return; } const current = state(); diff --git a/src/browser/server-context.existing-session.test.ts b/src/browser/server-context.existing-session.test.ts index abbd222342e..7092bbf1fd9 100644 --- a/src/browser/server-context.existing-session.test.ts +++ b/src/browser/server-context.existing-session.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createBrowserRouteContext } from "./server-context.js"; import type { BrowserServerState } from "./server-context.js"; @@ -47,6 +48,7 @@ function makeState(): BrowserServerState { color: "#0066CC", driver: "existing-session", attachOnly: true, + userDataDir: "/tmp/brave-profile", }, }, extraArgs: [], @@ -62,6 +64,7 @@ afterEach(() => { describe("browser server-context existing-session profile", () => { it("routes tab operations through the Chrome MCP backend", async () => { + fs.mkdirSync("/tmp/brave-profile", { recursive: true }); const state = makeState(); const ctx = createBrowserRouteContext({ getState: () => state }); const live = ctx.forProfile("chrome-live"); @@ -93,10 +96,21 @@ describe("browser server-context existing-session profile", () => { await live.focusTab("7"); await live.stopRunningBrowser(); - expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith("chrome-live"); - expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live"); - expect(chromeMcp.openChromeMcpTab).toHaveBeenCalledWith("chrome-live", "https://openclaw.ai"); - expect(chromeMcp.focusChromeMcpTab).toHaveBeenCalledWith("chrome-live", "7"); + expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith( + "chrome-live", + "/tmp/brave-profile", + ); + expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live", "/tmp/brave-profile"); + expect(chromeMcp.openChromeMcpTab).toHaveBeenCalledWith( + "chrome-live", + "https://openclaw.ai", + "/tmp/brave-profile", + ); + expect(chromeMcp.focusChromeMcpTab).toHaveBeenCalledWith( + "chrome-live", + "7", + "/tmp/brave-profile", + ); expect(chromeMcp.closeChromeMcpSession).toHaveBeenCalledWith("chrome-live"); }); }); diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index 1a744e06b09..24248cebfd8 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -94,7 +94,7 @@ export function createProfileSelectionOps({ const resolvedTargetId = await resolveTargetIdOrThrow(targetId); if (capabilities.usesChromeMcp) { - await focusChromeMcpTab(profile.name, resolvedTargetId); + await focusChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir); const profileState = getProfileState(); profileState.lastTargetId = resolvedTargetId; return; @@ -124,7 +124,7 @@ export function createProfileSelectionOps({ const resolvedTargetId = await resolveTargetIdOrThrow(targetId); if (capabilities.usesChromeMcp) { - await closeChromeMcpTab(profile.name, resolvedTargetId); + await closeChromeMcpTab(profile.name, resolvedTargetId, profile.userDataDir); return; } diff --git a/src/browser/server-context.tab-ops.ts b/src/browser/server-context.tab-ops.ts index 66a134564c6..747082a7ff5 100644 --- a/src/browser/server-context.tab-ops.ts +++ b/src/browser/server-context.tab-ops.ts @@ -67,7 +67,7 @@ export function createProfileTabOps({ const listTabs = async (): Promise => { if (capabilities.usesChromeMcp) { - return await listChromeMcpTabs(profile.name); + return await listChromeMcpTabs(profile.name, profile.userDataDir); } if (capabilities.usesPersistentPlaywright) { @@ -141,7 +141,7 @@ export function createProfileTabOps({ if (capabilities.usesChromeMcp) { await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); - const page = await openChromeMcpTab(profile.name, url); + const page = await openChromeMcpTab(profile.name, url, profile.userDataDir); const profileState = getProfileState(); profileState.lastTargetId = page.targetId; await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts }); diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts index 5ad1d5f7bd2..8b997b8ac30 100644 --- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { fetch as realFetch } from "undici"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -126,10 +127,47 @@ describe("profile CRUD endpoints", () => { profile?: string; transport?: string; cdpPort?: number | null; + userDataDir?: string | null; }; expect(createClawdBody.profile).toBe("legacyclawd"); expect(createClawdBody.transport).toBe("cdp"); expect(createClawdBody.cdpPort).toBeTypeOf("number"); + expect(createClawdBody.userDataDir).toBeNull(); + + const explicitUserDataDir = "/tmp/openclaw-brave-profile"; + await fs.promises.mkdir(explicitUserDataDir, { recursive: true }); + const createExistingSession = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "brave-live", + driver: "existing-session", + userDataDir: explicitUserDataDir, + }), + }); + expect(createExistingSession.status).toBe(200); + const createExistingSessionBody = (await createExistingSession.json()) as { + profile?: string; + transport?: string; + userDataDir?: string | null; + }; + expect(createExistingSessionBody.profile).toBe("brave-live"); + expect(createExistingSessionBody.transport).toBe("chrome-mcp"); + expect(createExistingSessionBody.userDataDir).toBe(explicitUserDataDir); + + const createBadExistingSession = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "bad-live", + userDataDir: explicitUserDataDir, + }), + }); + expect(createBadExistingSession.status).toBe(400); + const createBadExistingSessionBody = (await createBadExistingSession.json()) as { + error: string; + }; + expect(createBadExistingSessionBody.error).toContain("driver=existing-session is required"); const createLegacyDriver = await realFetch(`${base}/profiles/create`, { method: "POST", diff --git a/src/channels/config-presence.test.ts b/src/channels/config-presence.test.ts new file mode 100644 index 00000000000..1a5e7fa6f1f --- /dev/null +++ b/src/channels/config-presence.test.ts @@ -0,0 +1,41 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + hasMeaningfulChannelConfig, + hasPotentialConfiguredChannels, + listPotentialConfiguredChannelIds, +} from "./config-presence.js"; + +const tempDirs: string[] = []; + +function makeTempStateDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-config-presence-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("config presence", () => { + it("treats enabled-only channel sections as not meaningfully configured", () => { + expect(hasMeaningfulChannelConfig({ enabled: false })).toBe(false); + expect(hasMeaningfulChannelConfig({ enabled: true })).toBe(false); + expect(hasMeaningfulChannelConfig({})).toBe(false); + expect(hasMeaningfulChannelConfig({ homeserver: "https://matrix.example.org" })).toBe(true); + }); + + it("ignores enabled-only matrix config when listing configured channels", () => { + const stateDir = makeTempStateDir(); + const env = { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv; + const cfg = { channels: { matrix: { enabled: false } } }; + + expect(listPotentialConfiguredChannelIds(cfg, env)).toEqual([]); + expect(hasPotentialConfiguredChannels(cfg, env)).toBe(false); + }); +}); diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index d9add345eeb..e08b2b7d007 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -30,8 +30,11 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } -function recordHasKeys(value: unknown): boolean { - return isRecord(value) && Object.keys(value).length > 0; +export function hasMeaningfulChannelConfig(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return Object.keys(value).some((key) => key !== "enabled"); } function hasWhatsAppAuthState(env: NodeJS.ProcessEnv): boolean { @@ -71,7 +74,7 @@ export function listPotentialConfiguredChannelIds( if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) { continue; } - if (recordHasKeys(value)) { + if (hasMeaningfulChannelConfig(value)) { configuredChannelIds.add(key); } } @@ -121,7 +124,7 @@ export function hasPotentialConfiguredChannels( if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) { continue; } - if (recordHasKeys(value)) { + if (hasMeaningfulChannelConfig(value)) { return true; } } diff --git a/src/channels/plugins/contracts/group-policy.contract.test.ts b/src/channels/plugins/contracts/group-policy.contract.test.ts new file mode 100644 index 00000000000..51a9c178170 --- /dev/null +++ b/src/channels/plugins/contracts/group-policy.contract.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { __testing as discordTesting } from "../../../../extensions/discord/src/monitor/provider.js"; +import { __testing as imessageTesting } from "../../../../extensions/imessage/src/monitor/monitor-provider.js"; +import { __testing as slackTesting } from "../../../../extensions/slack/src/monitor/provider.js"; +import { resolveTelegramRuntimeGroupPolicy } from "../../../../extensions/telegram/src/group-access.js"; +import { __testing as whatsappTesting } from "../../../../extensions/whatsapp/src/inbound/access-control.js"; +import { __testing as zaloTesting } from "../../../../extensions/zalo/src/monitor.js"; +import { installChannelRuntimeGroupPolicyFallbackSuite } from "./suites.js"; + +describe("channel runtime group policy contract", () => { + describe("slack", () => { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: slackTesting.resolveSlackRuntimeGroupPolicy, + configuredLabel: "keeps open default when channels.slack is configured", + defaultGroupPolicyUnderTest: "open", + missingConfigLabel: "fails closed when channels.slack is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); + }); + + describe("telegram", () => { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: resolveTelegramRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.telegram is configured", + defaultGroupPolicyUnderTest: "disabled", + missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set", + missingDefaultLabel: "ignores explicit defaults when provider config is missing", + }); + }); + + describe("whatsapp", () => { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: whatsappTesting.resolveWhatsAppRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.whatsapp is configured", + defaultGroupPolicyUnderTest: "disabled", + missingConfigLabel: "fails closed when channels.whatsapp is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); + }); + + describe("imessage", () => { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: imessageTesting.resolveIMessageRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.imessage is configured", + defaultGroupPolicyUnderTest: "disabled", + missingConfigLabel: "fails closed when channels.imessage is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); + }); + + describe("discord", () => { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: discordTesting.resolveDiscordRuntimeGroupPolicy, + configuredLabel: "keeps open default when channels.discord is configured", + defaultGroupPolicyUnderTest: "open", + missingConfigLabel: "fails closed when channels.discord is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); + + it("respects explicit provider policy", () => { + const resolved = discordTesting.resolveDiscordRuntimeGroupPolicy({ + providerConfigPresent: false, + groupPolicy: "disabled", + }); + expect(resolved.groupPolicy).toBe("disabled"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); + }); + + describe("zalo", () => { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: zaloTesting.resolveZaloRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.zalo is configured", + defaultGroupPolicyUnderTest: "open", + missingConfigLabel: "fails closed when channels.zalo is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); + + it("keeps provider-owned group access evaluation", () => { + const decision = zaloTesting.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["zl:12345"], + senderId: "12345", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "allowlist", + reason: "allowed", + }); + }); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.discord.contract.test.ts b/src/channels/plugins/contracts/inbound.discord.contract.test.ts new file mode 100644 index 00000000000..6b168f7d244 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.discord.contract.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { inboundCtxCapture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const { processDiscordMessage } = + await import("../../../../extensions/discord/src/monitor/message-handler.process.js"); +const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } = + await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js"); + +describe("discord inbound contract", () => { + it("keeps inbound context finalized", async () => { + inboundCtxCapture.ctx = undefined; + const messageCtx = await createBaseDiscordMessageContext({ + cfg: { messages: {} }, + ackReactionScope: "direct", + ...createDiscordDirectMessageContextOverrides(), + }); + + await processDiscordMessage(messageCtx); + + expect(inboundCtxCapture.ctx).toBeTruthy(); + expectChannelInboundContextContract(inboundCtxCapture.ctx!); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.signal.contract.test.ts b/src/channels/plugins/contracts/inbound.signal.contract.test.ts new file mode 100644 index 00000000000..abec31c0174 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.signal.contract.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createSignalEventHandler } from "../../../../extensions/signal/src/monitor/event-handler.js"; +import { + createBaseSignalEventHandlerDeps, + createSignalReceiveEvent, +} from "../../../../extensions/signal/src/monitor/event-handler.test-harness.js"; +import type { MsgContext } from "../../../auto-reply/templating.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const capture = vi.hoisted(() => ({ ctx: undefined as MsgContext | undefined })); +const dispatchInboundMessageMock = vi.hoisted(() => + vi.fn( + async (params: { + ctx: MsgContext; + replyOptions?: { onReplyStart?: () => void | Promise }; + }) => { + capture.ctx = params.ctx; + await Promise.resolve(params.replyOptions?.onReplyStart?.()); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; + }, + ), +); + +vi.mock("../../../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchInboundMessage: dispatchInboundMessageMock, + dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock, + }; +}); + +vi.mock("../../../../extensions/signal/src/send.js", () => ({ + sendMessageSignal: vi.fn(), + sendTypingSignal: vi.fn(async () => true), + sendReadReceiptSignal: vi.fn(async () => true), +})); + +vi.mock("../../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: vi.fn().mockResolvedValue([]), + upsertChannelPairingRequest: vi.fn(), +})); + +describe("signal inbound contract", () => { + beforeEach(() => { + capture.ctx = undefined; + dispatchInboundMessageMock.mockClear(); + }); + + it("keeps inbound context finalized", async () => { + const handler = createSignalEventHandler( + createBaseSignalEventHandlerDeps({ + // oxlint-disable-next-line typescript/no-explicit-any + cfg: { messages: { inbound: { debounceMs: 0 } } } as any, + historyLimit: 0, + }), + ); + + await handler( + createSignalReceiveEvent({ + dataMessage: { + message: "hi", + attachments: [], + groupInfo: { groupId: "g1", groupName: "Test Group" }, + }, + }), + ); + + expect(capture.ctx).toBeTruthy(); + expectChannelInboundContextContract(capture.ctx!); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.slack.contract.test.ts b/src/channels/plugins/contracts/inbound.slack.contract.test.ts new file mode 100644 index 00000000000..e013bed3b4f --- /dev/null +++ b/src/channels/plugins/contracts/inbound.slack.contract.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js"; +import { prepareSlackMessage } from "../../../../extensions/slack/src/monitor/message-handler/prepare.js"; +import { createInboundSlackTestContext } from "../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js"; +import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { + return { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + userTokenSource: "none", + config, + replyToMode: config.replyToMode, + replyToModeByChatType: config.replyToModeByChatType, + dm: config.dm, + }; +} + +function createSlackMessage(overrides: Partial): SlackMessageEvent { + return { + channel: "D123", + channel_type: "im", + user: "U1", + text: "hi", + ts: "1.000", + ...overrides, + } as SlackMessageEvent; +} + +describe("slack inbound contract", () => { + it("keeps inbound context finalized", async () => { + const ctx = createInboundSlackTestContext({ + cfg: { + channels: { slack: { enabled: true } }, + } as OpenClawConfig, + }); + // oxlint-disable-next-line typescript/no-explicit-any + ctx.resolveUserName = async () => ({ name: "Alice" }) as any; + + const prepared = await prepareSlackMessage({ + ctx, + account: createSlackAccount(), + message: createSlackMessage({}), + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + expectChannelInboundContextContract(prepared!.ctxPayload); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.telegram.contract.test.ts b/src/channels/plugins/contracts/inbound.telegram.contract.test.ts new file mode 100644 index 00000000000..a872964bd53 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.telegram.contract.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + getLoadConfigMock, + getOnHandler, + onSpy, + replySpy, +} from "../../../../extensions/telegram/src/bot.create-telegram-bot.test-harness.js"; +import type { MsgContext } from "../../../auto-reply/templating.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const { createTelegramBot } = await import("../../../../extensions/telegram/src/bot.js"); + +describe("telegram inbound contract", () => { + const loadConfig = getLoadConfigMock(); + + beforeEach(() => { + onSpy.mockClear(); + replySpy.mockClear(); + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + } satisfies OpenClawConfig); + }); + + it("keeps inbound context finalized", async () => { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + message_id: 2, + from: { + id: 99, + first_name: "Ada", + last_name: "Lovelace", + username: "ada", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + const payload = replySpy.mock.calls[0]?.[0] as MsgContext | undefined; + expect(payload).toBeTruthy(); + expectChannelInboundContextContract(payload!); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts b/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts new file mode 100644 index 00000000000..108131226aa --- /dev/null +++ b/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts @@ -0,0 +1,111 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { processMessage } from "../../../../extensions/whatsapp/src/auto-reply/monitor/process-message.js"; +import type { MsgContext } from "../../../auto-reply/templating.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const capture = vi.hoisted(() => ({ + ctx: undefined as MsgContext | undefined, +})); + +vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({ + dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: { ctx: MsgContext }) => { + capture.ctx = params.ctx; + return { queuedFinal: false }; + }), +})); + +vi.mock("../../../../extensions/whatsapp/src/auto-reply/monitor/last-route.js", () => ({ + trackBackgroundTask: (tasks: Set>, task: Promise) => { + tasks.add(task); + void task.finally(() => { + tasks.delete(task); + }); + }, + updateLastRouteInBackground: vi.fn(), +})); + +vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () => ({ + deliverWebReply: vi.fn(async () => {}), +})); + +function makeProcessArgs(sessionStorePath: string) { + return { + // oxlint-disable-next-line typescript/no-explicit-any + cfg: { messages: {}, session: { store: sessionStorePath } } as any, + // oxlint-disable-next-line typescript/no-explicit-any + msg: { + id: "msg1", + from: "123@g.us", + to: "+15550001111", + chatType: "group", + body: "hi", + senderName: "Alice", + senderJid: "alice@s.whatsapp.net", + senderE164: "+15550002222", + groupSubject: "Test Group", + groupParticipants: [], + } as unknown as Record, + route: { + agentId: "main", + accountId: "default", + sessionKey: "agent:main:whatsapp:group:123", + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + groupHistoryKey: "123@g.us", + groupHistories: new Map(), + groupMemberNames: new Map(), + connectionId: "conn", + verbose: false, + maxMediaBytes: 1, + // oxlint-disable-next-line typescript/no-explicit-any + replyResolver: (async () => undefined) as any, + // oxlint-disable-next-line typescript/no-explicit-any + replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any, + backgroundTasks: new Set>(), + rememberSentText: () => {}, + echoHas: () => false, + echoForget: () => {}, + buildCombinedEchoKey: () => "echo", + groupHistory: [], + // oxlint-disable-next-line typescript/no-explicit-any + } as any; +} + +async function removeDirEventually(dir: string) { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await fs.rm(dir, { recursive: true, force: true }); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOTEMPTY" || attempt === 2) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } +} + +describe("whatsapp inbound contract", () => { + let sessionDir = ""; + + afterEach(async () => { + capture.ctx = undefined; + if (sessionDir) { + await removeDirEventually(sessionDir); + sessionDir = ""; + } + }); + + it("keeps inbound context finalized", async () => { + sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-whatsapp-contract-")); + const sessionStorePath = path.join(sessionDir, "sessions.json"); + + await processMessage(makeProcessArgs(sessionStorePath)); + + expect(capture.ctx).toBeTruthy(); + expectChannelInboundContextContract(capture.ctx!); + }); +}); diff --git a/src/channels/plugins/contracts/outbound-payload.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.contract.test.ts new file mode 100644 index 00000000000..5faa47893cb --- /dev/null +++ b/src/channels/plugins/contracts/outbound-payload.contract.test.ts @@ -0,0 +1,209 @@ +import { describe, vi } from "vitest"; +import { discordOutbound } from "../../../../extensions/discord/src/outbound-adapter.js"; +import { whatsappOutbound } from "../../../../extensions/whatsapp/src/outbound-adapter.js"; +import { zaloPlugin } from "../../../../extensions/zalo/src/channel.js"; +import { sendMessageZalo } from "../../../../extensions/zalo/src/send.js"; +import "./../../../../extensions/zalouser/src/accounts.test-mocks.js"; +import { zalouserPlugin } from "../../../../extensions/zalouser/src/channel.js"; +import { setZalouserRuntime } from "../../../../extensions/zalouser/src/runtime.js"; +import { sendMessageZalouser } from "../../../../extensions/zalouser/src/send.js"; +import { slackOutbound } from "../../../../test/channel-outbounds.js"; +import type { ReplyPayload } from "../../../auto-reply/types.js"; +import { createDirectTextMediaOutbound } from "../outbound/direct-text-media.js"; +import { + installChannelOutboundPayloadContractSuite, + primeChannelOutboundSendMock, +} from "./suites.js"; + +vi.mock("../../../../extensions/zalo/src/send.js", () => ({ + sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }), +})); + +vi.mock("../../../../extensions/zalouser/src/send.js", () => ({ + sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }), + sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true }), +})); + +type PayloadHarnessParams = { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}; + +const mockedSendZalo = vi.mocked(sendMessageZalo); +const mockedSendZalouser = vi.mocked(sendMessageZalouser); + +function createSlackHarness(params: PayloadHarnessParams) { + const sendSlack = vi.fn(); + primeChannelOutboundSendMock( + sendSlack, + { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, + params.sendResults, + ); + const ctx = { + cfg: {}, + to: "C12345", + text: "", + payload: params.payload, + deps: { + sendSlack, + }, + }; + return { + run: async () => await slackOutbound.sendPayload!(ctx), + sendMock: sendSlack, + to: ctx.to, + }; +} + +function createDiscordHarness(params: PayloadHarnessParams) { + const sendDiscord = vi.fn(); + primeChannelOutboundSendMock( + sendDiscord, + { messageId: "dc-1", channelId: "123456" }, + params.sendResults, + ); + const ctx = { + cfg: {}, + to: "channel:123456", + text: "", + payload: params.payload, + deps: { + sendDiscord, + }, + }; + return { + run: async () => await discordOutbound.sendPayload!(ctx), + sendMock: sendDiscord, + to: ctx.to, + }; +} + +function createWhatsAppHarness(params: PayloadHarnessParams) { + const sendWhatsApp = vi.fn(); + primeChannelOutboundSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults); + const ctx = { + cfg: {}, + to: "5511999999999@c.us", + text: "", + payload: params.payload, + deps: { + sendWhatsApp, + }, + }; + return { + run: async () => await whatsappOutbound.sendPayload!(ctx), + sendMock: sendWhatsApp, + to: ctx.to, + }; +} + +function createDirectTextMediaHarness(params: PayloadHarnessParams) { + const sendFn = vi.fn(); + primeChannelOutboundSendMock(sendFn, { messageId: "m1" }, params.sendResults); + const outbound = createDirectTextMediaOutbound({ + channel: "imessage", + resolveSender: () => sendFn, + resolveMaxBytes: () => undefined, + buildTextOptions: (opts) => opts as never, + buildMediaOptions: (opts) => opts as never, + }); + const ctx = { + cfg: {}, + to: "user1", + text: "", + payload: params.payload, + }; + return { + run: async () => await outbound.sendPayload!(ctx), + sendMock: sendFn, + to: ctx.to, + }; +} + +describe("channel outbound payload contract", () => { + describe("slack", () => { + installChannelOutboundPayloadContractSuite({ + channel: "slack", + chunking: { mode: "passthrough", longTextLength: 5000 }, + createHarness: createSlackHarness, + }); + }); + + describe("discord", () => { + installChannelOutboundPayloadContractSuite({ + channel: "discord", + chunking: { mode: "passthrough", longTextLength: 3000 }, + createHarness: createDiscordHarness, + }); + }); + + describe("whatsapp", () => { + installChannelOutboundPayloadContractSuite({ + channel: "whatsapp", + chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, + createHarness: createWhatsAppHarness, + }); + }); + + describe("zalo", () => { + installChannelOutboundPayloadContractSuite({ + channel: "zalo", + chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, + createHarness: ({ payload, sendResults }) => { + primeChannelOutboundSendMock(mockedSendZalo, { ok: true, messageId: "zl-1" }, sendResults); + return { + run: async () => + await zaloPlugin.outbound!.sendPayload!({ + cfg: {}, + to: "123456789", + text: "", + payload, + }), + sendMock: mockedSendZalo, + to: "123456789", + }; + }, + }); + }); + + describe("zalouser", () => { + installChannelOutboundPayloadContractSuite({ + channel: "zalouser", + chunking: { mode: "passthrough", longTextLength: 3000 }, + createHarness: ({ payload, sendResults }) => { + setZalouserRuntime({ + channel: { + text: { + resolveChunkMode: vi.fn(() => "length"), + resolveTextChunkLimit: vi.fn(() => 1200), + }, + }, + } as never); + primeChannelOutboundSendMock( + mockedSendZalouser, + { ok: true, messageId: "zlu-1" }, + sendResults, + ); + return { + run: async () => + await zalouserPlugin.outbound!.sendPayload!({ + cfg: {}, + to: "user:987654321", + text: "", + payload, + }), + sendMock: mockedSendZalouser, + to: "987654321", + }; + }, + }); + }); + + describe("direct-text-media", () => { + installChannelOutboundPayloadContractSuite({ + channel: "imessage", + chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, + createHarness: createDirectTextMediaHarness, + }); + }); +}); diff --git a/src/channels/plugins/contracts/registry.contract.test.ts b/src/channels/plugins/contracts/registry.contract.test.ts new file mode 100644 index 00000000000..69ff11d8e68 --- /dev/null +++ b/src/channels/plugins/contracts/registry.contract.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + actionContractRegistry, + pluginContractRegistry, + setupContractRegistry, + statusContractRegistry, + surfaceContractRegistry, + type ChannelPluginSurface, +} from "./registry.js"; + +const orderedSurfaceKeys = [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", +] as const satisfies readonly ChannelPluginSurface[]; + +describe("channel contract registry", () => { + it("does not duplicate channel plugin ids", () => { + const ids = pluginContractRegistry.map((entry) => entry.id); + expect(ids).toEqual([...new Set(ids)]); + }); + + it("keeps the surface registry aligned with the plugin registry", () => { + expect(surfaceContractRegistry.map((entry) => entry.id).toSorted()).toEqual( + pluginContractRegistry.map((entry) => entry.id).toSorted(), + ); + }); + + it("declares the actual owned channel plugin surfaces explicitly", () => { + for (const entry of surfaceContractRegistry) { + const actual = orderedSurfaceKeys.filter((surface) => Boolean(entry.plugin[surface])); + expect([...entry.surfaces].toSorted()).toEqual(actual.toSorted()); + } + }); + + it("only installs deep action coverage for plugins that declare actions", () => { + const actionSurfaceIds = new Set( + surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("actions")) + .map((entry) => entry.id), + ); + for (const entry of actionContractRegistry) { + expect(actionSurfaceIds.has(entry.id)).toBe(true); + } + }); + + it("only installs deep setup coverage for plugins that declare setup", () => { + const setupSurfaceIds = new Set( + surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("setup")) + .map((entry) => entry.id), + ); + for (const entry of setupContractRegistry) { + expect(setupSurfaceIds.has(entry.id)).toBe(true); + } + }); + + it("only installs deep status coverage for plugins that declare status", () => { + const statusSurfaceIds = new Set( + surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("status")) + .map((entry) => entry.id), + ); + for (const entry of statusContractRegistry) { + expect(statusSurfaceIds.has(entry.id)).toBe(true); + } + }); +}); diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 567181cef46..2d4569383f8 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -57,6 +57,33 @@ type StatusContractEntry = { }>; }; +export type ChannelPluginSurface = + | "actions" + | "setup" + | "status" + | "outbound" + | "messaging" + | "threading" + | "directory" + | "gateway"; + +type SurfaceContractEntry = { + id: string; + plugin: Pick< + ChannelPlugin, + | "id" + | "actions" + | "setup" + | "status" + | "outbound" + | "messaging" + | "threading" + | "directory" + | "gateway" + >; + surfaces: readonly ChannelPluginSurface[]; +}; + const telegramListActionsMock = vi.fn(); const telegramGetCapabilitiesMock = vi.fn(); const discordListActionsMock = vi.fn(); @@ -461,3 +488,187 @@ export const statusContractRegistry: StatusContractEntry[] = [ ], }, ]; + +export const surfaceContractRegistry: SurfaceContractEntry[] = [ + { + id: "bluebubbles", + plugin: requireBundledChannelPlugin("bluebubbles"), + surfaces: ["actions", "setup", "status", "outbound", "messaging", "threading", "gateway"], + }, + { + id: "discord", + plugin: requireBundledChannelPlugin("discord"), + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "feishu", + plugin: requireBundledChannelPlugin("feishu"), + surfaces: ["actions", "setup", "status", "outbound", "messaging", "directory", "gateway"], + }, + { + id: "googlechat", + plugin: requireBundledChannelPlugin("googlechat"), + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "imessage", + plugin: requireBundledChannelPlugin("imessage"), + surfaces: ["setup", "status", "outbound", "messaging", "gateway"], + }, + { + id: "irc", + plugin: requireBundledChannelPlugin("irc"), + surfaces: ["setup", "status", "outbound", "messaging", "directory", "gateway"], + }, + { + id: "line", + plugin: requireBundledChannelPlugin("line"), + surfaces: ["setup", "status", "outbound", "messaging", "directory", "gateway"], + }, + { + id: "matrix", + plugin: requireBundledChannelPlugin("matrix"), + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "mattermost", + plugin: requireBundledChannelPlugin("mattermost"), + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "msteams", + plugin: requireBundledChannelPlugin("msteams"), + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "nextcloud-talk", + plugin: requireBundledChannelPlugin("nextcloud-talk"), + surfaces: ["setup", "status", "outbound", "messaging", "gateway"], + }, + { + id: "nostr", + plugin: requireBundledChannelPlugin("nostr"), + surfaces: ["setup", "status", "outbound", "messaging", "gateway"], + }, + { + id: "signal", + plugin: requireBundledChannelPlugin("signal"), + surfaces: ["actions", "setup", "status", "outbound", "messaging", "gateway"], + }, + { + id: "slack", + plugin: requireBundledChannelPlugin("slack"), + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "synology-chat", + plugin: requireBundledChannelPlugin("synology-chat"), + surfaces: ["setup", "outbound", "messaging", "directory", "gateway"], + }, + { + id: "telegram", + plugin: requireBundledChannelPlugin("telegram"), + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "tlon", + plugin: requireBundledChannelPlugin("tlon"), + surfaces: ["setup", "status", "outbound", "messaging", "gateway"], + }, + { + id: "whatsapp", + plugin: requireBundledChannelPlugin("whatsapp"), + surfaces: ["actions", "setup", "status", "outbound", "messaging", "directory", "gateway"], + }, + { + id: "zalo", + plugin: requireBundledChannelPlugin("zalo"), + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "zalouser", + plugin: requireBundledChannelPlugin("zalouser"), + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, +]; diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index fc79d26fa07..f2c8a8e3b16 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -1,5 +1,13 @@ -import { expect, it } from "vitest"; +import { expect, it, type Mock } from "vitest"; +import type { MsgContext } from "../../../auto-reply/templating.js"; import type { OpenClawConfig } from "../../../config/config.js"; +import type { + ResolveProviderRuntimeGroupPolicyParams, + RuntimeGroupPolicyResolution, +} from "../../../config/runtime-group-policy.js"; +import { normalizeChatType } from "../../chat-type.js"; +import { resolveConversationLabel } from "../../conversation-label.js"; +import { validateSenderIdentity } from "../../sender-identity.js"; import type { ChannelAccountSnapshot, ChannelAccountState, @@ -84,6 +92,142 @@ export function installChannelActionsContractSuite(params: { } } +export function installChannelSurfaceContractSuite(params: { + plugin: Pick< + ChannelPlugin, + | "id" + | "actions" + | "setup" + | "status" + | "outbound" + | "messaging" + | "threading" + | "directory" + | "gateway" + >; + surface: + | "actions" + | "setup" + | "status" + | "outbound" + | "messaging" + | "threading" + | "directory" + | "gateway"; +}) { + const { plugin, surface } = params; + + it(`exposes the ${surface} surface contract`, () => { + if (surface === "actions") { + expect(plugin.actions).toBeDefined(); + expect(typeof plugin.actions?.listActions).toBe("function"); + return; + } + + if (surface === "setup") { + expect(plugin.setup).toBeDefined(); + expect(typeof plugin.setup?.applyAccountConfig).toBe("function"); + return; + } + + if (surface === "status") { + expect(plugin.status).toBeDefined(); + expect(typeof plugin.status?.buildAccountSnapshot).toBe("function"); + return; + } + + if (surface === "outbound") { + const outbound = plugin.outbound; + expect(outbound).toBeDefined(); + expect(["direct", "gateway", "hybrid"]).toContain(outbound?.deliveryMode); + expect( + [ + outbound?.sendPayload, + outbound?.sendFormattedText, + outbound?.sendFormattedMedia, + outbound?.sendText, + outbound?.sendMedia, + outbound?.sendPoll, + ].some((value) => typeof value === "function"), + ).toBe(true); + return; + } + + if (surface === "messaging") { + const messaging = plugin.messaging; + expect(messaging).toBeDefined(); + expect( + [ + messaging?.normalizeTarget, + messaging?.parseExplicitTarget, + messaging?.inferTargetChatType, + messaging?.buildCrossContextComponents, + messaging?.enableInteractiveReplies, + messaging?.hasStructuredReplyPayload, + messaging?.formatTargetDisplay, + messaging?.resolveOutboundSessionRoute, + ].some((value) => typeof value === "function"), + ).toBe(true); + if (messaging?.targetResolver) { + if (messaging.targetResolver.looksLikeId) { + expect(typeof messaging.targetResolver.looksLikeId).toBe("function"); + } + if (messaging.targetResolver.hint !== undefined) { + expect(typeof messaging.targetResolver.hint).toBe("string"); + expect(messaging.targetResolver.hint.trim()).not.toBe(""); + } + if (messaging.targetResolver.resolveTarget) { + expect(typeof messaging.targetResolver.resolveTarget).toBe("function"); + } + } + return; + } + + if (surface === "threading") { + const threading = plugin.threading; + expect(threading).toBeDefined(); + expect( + [ + threading?.resolveReplyToMode, + threading?.buildToolContext, + threading?.resolveAutoThreadId, + threading?.resolveReplyTransport, + threading?.resolveFocusedBinding, + ].some((value) => typeof value === "function"), + ).toBe(true); + return; + } + + if (surface === "directory") { + const directory = plugin.directory; + expect(directory).toBeDefined(); + expect( + [ + directory?.self, + directory?.listPeers, + directory?.listPeersLive, + directory?.listGroups, + directory?.listGroupsLive, + directory?.listGroupMembers, + ].some((value) => typeof value === "function"), + ).toBe(true); + return; + } + + const gateway = plugin.gateway; + expect(gateway).toBeDefined(); + expect( + [ + gateway?.startAccount, + gateway?.stopAccount, + gateway?.loginWithQrStart, + gateway?.loginWithQrWait, + gateway?.logoutAccount, + ].some((value) => typeof value === "function"), + ).toBe(true); + }); +} + type ChannelSetupContractCase = { name: string; cfg: OpenClawConfig; @@ -214,3 +358,191 @@ export function installChannelStatusContractSuite { + run: () => Promise>; + sendMock: Mock; + to: string; + }; +}) { + it("text-only delegates to sendText", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { text: "hello" }, + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith(to, "hello", expect.any(Object)); + expect(result).toMatchObject({ channel: params.channel }); + }); + + it("single media delegates to sendMedia", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" }, + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith( + to, + "cap", + expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), + ); + expect(result).toMatchObject({ channel: params.channel }); + }); + + it("multi-media iterates URLs with caption on first", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { + text: "caption", + mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], + }, + sendResults: [{ messageId: "m-1" }, { messageId: "m-2" }], + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(2); + expect(sendMock).toHaveBeenNthCalledWith( + 1, + to, + "caption", + expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), + ); + expect(sendMock).toHaveBeenNthCalledWith( + 2, + to, + "", + expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), + ); + expect(result).toMatchObject({ channel: params.channel, messageId: "m-2" }); + }); + + it("empty payload returns no-op", async () => { + const { run, sendMock } = params.createHarness({ payload: {} }); + const result = await run(); + + expect(sendMock).not.toHaveBeenCalled(); + expect(result).toEqual({ channel: params.channel, messageId: "" }); + }); + + if (params.chunking.mode === "passthrough") { + it("text exceeding chunk limit is sent as-is when chunker is null", async () => { + const text = "a".repeat(params.chunking.longTextLength); + const { run, sendMock, to } = params.createHarness({ payload: { text } }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith(to, text, expect.any(Object)); + expect(result).toMatchObject({ channel: params.channel }); + }); + return; + } + + const chunking = params.chunking; + + it("chunking splits long text", async () => { + const text = "a".repeat(chunking.longTextLength); + const { run, sendMock } = params.createHarness({ + payload: { text }, + sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }], + }); + const result = await run(); + + expect(sendMock.mock.calls.length).toBeGreaterThanOrEqual(2); + for (const call of sendMock.mock.calls) { + expect((call[1] as string).length).toBeLessThanOrEqual(chunking.maxChunkLength); + } + expect(result).toMatchObject({ channel: params.channel }); + }); +} + +export function primeChannelOutboundSendMock( + sendMock: Mock, + fallbackResult: Record, + sendResults: SendResultLike[] = [], +) { + sendMock.mockReset(); + if (sendResults.length === 0) { + sendMock.mockResolvedValue(fallbackResult); + return; + } + for (const result of sendResults) { + sendMock.mockResolvedValueOnce(result); + } +} + +type RuntimeGroupPolicyResolver = ( + params: ResolveProviderRuntimeGroupPolicyParams, +) => RuntimeGroupPolicyResolution; + +export function installChannelRuntimeGroupPolicyFallbackSuite(params: { + configuredLabel: string; + defaultGroupPolicyUnderTest: "allowlist" | "disabled" | "open"; + missingConfigLabel: string; + missingDefaultLabel: string; + resolve: RuntimeGroupPolicyResolver; +}) { + it(params.missingConfigLabel, () => { + const resolved = params.resolve({ + providerConfigPresent: false, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); + + it(params.configuredLabel, () => { + const resolved = params.resolve({ + providerConfigPresent: true, + }); + expect(resolved.groupPolicy).toBe("open"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); + + it(params.missingDefaultLabel, () => { + const resolved = params.resolve({ + providerConfigPresent: false, + defaultGroupPolicy: params.defaultGroupPolicyUnderTest, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); +} + +export function expectChannelInboundContextContract(ctx: MsgContext) { + expect(validateSenderIdentity(ctx)).toEqual([]); + + expect(ctx.Body).toBeTypeOf("string"); + expect(ctx.BodyForAgent).toBeTypeOf("string"); + expect(ctx.BodyForCommands).toBeTypeOf("string"); + + const chatType = normalizeChatType(ctx.ChatType); + if (chatType && chatType !== "direct") { + const label = ctx.ConversationLabel?.trim() || resolveConversationLabel(ctx); + expect(label).toBeTruthy(); + } +} diff --git a/src/channels/plugins/contracts/surface.contract.test.ts b/src/channels/plugins/contracts/surface.contract.test.ts new file mode 100644 index 00000000000..6e657bd19fc --- /dev/null +++ b/src/channels/plugins/contracts/surface.contract.test.ts @@ -0,0 +1,14 @@ +import { describe } from "vitest"; +import { surfaceContractRegistry } from "./registry.js"; +import { installChannelSurfaceContractSuite } from "./suites.js"; + +for (const entry of surfaceContractRegistry) { + for (const surface of entry.surfaces) { + describe(`${entry.id} ${surface} surface contract`, () => { + installChannelSurfaceContractSuite({ + plugin: entry.plugin, + surface, + }); + }); + } +} diff --git a/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts b/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts deleted file mode 100644 index 42971f1e89c..00000000000 --- a/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, vi } from "vitest"; -import type { ReplyPayload } from "../../../auto-reply/types.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../test-utils/send-payload-contract.js"; -import { createDirectTextMediaOutbound } from "./direct-text-media.js"; - -function createDirectHarness(params: { - payload: ReplyPayload; - sendResults?: Array<{ messageId: string }>; -}) { - const sendFn = vi.fn(); - primeSendMock(sendFn, { messageId: "m1" }, params.sendResults); - const outbound = createDirectTextMediaOutbound({ - channel: "imessage", - resolveSender: () => sendFn, - resolveMaxBytes: () => undefined, - buildTextOptions: (opts) => opts as never, - buildMediaOptions: (opts) => opts as never, - }); - return { outbound, sendFn }; -} - -function baseCtx(payload: ReplyPayload) { - return { - cfg: {}, - to: "user1", - text: "", - payload, - }; -} - -describe("createDirectTextMediaOutbound sendPayload", () => { - installSendPayloadContractSuite({ - channel: "imessage", - chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, - createHarness: ({ payload, sendResults }) => { - const { outbound, sendFn } = createDirectHarness({ payload, sendResults }); - return { - run: async () => await outbound.sendPayload!(baseCtx(payload)), - sendMock: sendFn, - to: "user1", - }; - }, - }); -}); diff --git a/src/channels/plugins/outbound/slack.sendpayload.test.ts b/src/channels/plugins/outbound/slack.sendpayload.test.ts index e1175023858..a78916c1336 100644 --- a/src/channels/plugins/outbound/slack.sendpayload.test.ts +++ b/src/channels/plugins/outbound/slack.sendpayload.test.ts @@ -1,17 +1,14 @@ import { describe, expect, it, vi } from "vitest"; import { slackOutbound } from "../../../../test/channel-outbounds.js"; import type { ReplyPayload } from "../../../auto-reply/types.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../test-utils/send-payload-contract.js"; +import { primeChannelOutboundSendMock } from "../contracts/suites.js"; function createHarness(params: { payload: ReplyPayload; sendResults?: Array<{ messageId: string }>; }) { const sendSlack = vi.fn(); - primeSendMock( + primeChannelOutboundSendMock( sendSlack, { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, params.sendResults, @@ -33,12 +30,6 @@ function createHarness(params: { } describe("slackOutbound sendPayload", () => { - installSendPayloadContractSuite({ - channel: "slack", - chunking: { mode: "passthrough", longTextLength: 5000 }, - createHarness, - }); - it("forwards Slack blocks from channelData", async () => { const { run, sendMock, to } = createHarness({ payload: { diff --git a/src/channels/plugins/target-parsing.test.ts b/src/channels/plugins/target-parsing.test.ts new file mode 100644 index 00000000000..a1c5d278fde --- /dev/null +++ b/src/channels/plugins/target-parsing.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { parseExplicitTargetForChannel } from "./target-parsing.js"; + +describe("parseExplicitTargetForChannel", () => { + beforeEach(() => { + setActivePluginRegistry(createTestRegistry([])); + }); + + it("parses bundled Telegram targets without an active Telegram registry entry", () => { + expect(parseExplicitTargetForChannel("telegram", "telegram:group:-100123:topic:77")).toEqual({ + to: "-100123", + threadId: 77, + chatType: "group", + }); + expect(parseExplicitTargetForChannel("telegram", "-100123")).toEqual({ + to: "-100123", + chatType: "group", + }); + }); + + it("parses registered non-bundled channel targets via the active plugin contract", () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "msteams", + source: "test", + plugin: { + id: "msteams", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "test stub", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + messaging: { + parseExplicitTarget: ({ raw }: { raw: string }) => ({ + to: raw.trim().toUpperCase(), + chatType: "direct" as const, + }), + }, + }, + }, + ]), + ); + + expect(parseExplicitTargetForChannel("msteams", "team-room")).toEqual({ + to: "TEAM-ROOM", + chatType: "direct", + }); + }); +}); diff --git a/src/channels/plugins/target-parsing.ts b/src/channels/plugins/target-parsing.ts index 5d7fd6e28da..beea68adca3 100644 --- a/src/channels/plugins/target-parsing.ts +++ b/src/channels/plugins/target-parsing.ts @@ -1,4 +1,7 @@ +import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js"; +import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; import type { ChatType } from "../chat-type.js"; +import { normalizeChatChannelId } from "../registry.js"; import { getChannelPlugin, normalizeChannelId } from "./registry.js"; export type ParsedChannelExplicitTarget = { @@ -11,10 +14,28 @@ function parseWithPlugin( rawChannel: string, rawTarget: string, ): ParsedChannelExplicitTarget | null { - const channel = normalizeChannelId(rawChannel); + const channel = normalizeChatChannelId(rawChannel) ?? normalizeChannelId(rawChannel); if (!channel) { return null; } + if (channel === "telegram") { + const target = parseTelegramTarget(rawTarget); + return { + to: target.chatId, + ...(target.messageThreadId != null ? { threadId: target.messageThreadId } : {}), + ...(target.chatType === "unknown" ? {} : { chatType: target.chatType }), + }; + } + if (channel === "discord") { + const target = parseDiscordTarget(rawTarget, { defaultKind: "channel" }); + if (!target) { + return null; + } + return { + to: target.id, + chatType: target.kind === "user" ? "direct" : "channel", + }; + } return getChannelPlugin(channel)?.messaging?.parseExplicitTarget?.({ raw: rawTarget }) ?? null; } diff --git a/src/cli/browser-cli-manage.test.ts b/src/cli/browser-cli-manage.test.ts index deeb0d9e73a..86c10ac75ae 100644 --- a/src/cli/browser-cli-manage.test.ts +++ b/src/cli/browser-cli-manage.test.ts @@ -91,6 +91,42 @@ describe("browser manage output", () => { expect(output).not.toContain("cdpUrl:"); }); + it("shows configured userDataDir for existing-session status", async () => { + mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) => + req.path === "/" + ? { + enabled: true, + profile: "brave-live", + driver: "existing-session", + transport: "chrome-mcp", + running: true, + cdpReady: true, + cdpHttp: true, + pid: 4321, + cdpPort: null, + cdpUrl: null, + chosenBrowser: null, + userDataDir: "/Users/test/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + headless: false, + noSandbox: false, + executablePath: null, + attachOnly: true, + } + : {}, + ); + + const program = createProgram(); + await program.parseAsync(["browser", "--browser-profile", "brave-live", "status"], { + from: "user", + }); + + const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string; + expect(output).toContain( + "userDataDir: /Users/test/Library/Application Support/BraveSoftware/Brave-Browser", + ); + }); + it("shows chrome-mcp transport in browser profiles output", async () => { mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) => req.path === "/profiles" @@ -131,6 +167,7 @@ describe("browser manage output", () => { transport: "chrome-mcp", cdpPort: null, cdpUrl: null, + userDataDir: null, color: "#00AA00", isRemote: false, } diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index e13b7af003a..1c096b1a73b 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -116,9 +116,13 @@ function formatBrowserConnectionSummary(params: { isRemote?: boolean; cdpPort?: number | null; cdpUrl?: string | null; + userDataDir?: string | null; }): string { if (usesChromeMcpTransport(params)) { - return "transport: chrome-mcp"; + const userDataDir = params.userDataDir ? shortenHomePath(params.userDataDir) : null; + return userDataDir + ? `transport: chrome-mcp, userDataDir: ${userDataDir}` + : "transport: chrome-mcp"; } if (params.isRemote) { return `cdpUrl: ${params.cdpUrl ?? "(unset)"}`; @@ -155,7 +159,9 @@ export function registerBrowserManageCommands( `cdpPort: ${status.cdpPort ?? "(unset)"}`, `cdpUrl: ${redactCdpUrl(status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`)}`, ] - : []), + : status.userDataDir + ? [`userDataDir: ${shortenHomePath(status.userDataDir)}`] + : []), `browser: ${status.chosenBrowser ?? "unknown"}`, `detectedBrowser: ${status.detectedBrowser ?? "unknown"}`, `detectedPath: ${detectedDisplay}`, @@ -455,9 +461,19 @@ export function registerBrowserManageCommands( .requiredOption("--name ", "Profile name (lowercase, numbers, hyphens)") .option("--color ", "Profile color (hex format, e.g. #0066CC)") .option("--cdp-url ", "CDP URL for remote Chrome (http/https)") + .option("--user-data-dir ", "User data dir for existing-session Chromium attach") .option("--driver ", "Profile driver (openclaw|existing-session). Default: openclaw") .action( - async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => { + async ( + opts: { + name: string; + color?: string; + cdpUrl?: string; + userDataDir?: string; + driver?: string; + }, + cmd, + ) => { const parent = parentOpts(cmd); await runBrowserCommand(async () => { const result = await callBrowserRequest( @@ -469,6 +485,7 @@ export function registerBrowserManageCommands( name: opts.name, color: opts.color, cdpUrl: opts.cdpUrl, + userDataDir: opts.userDataDir, driver: opts.driver === "existing-session" ? "existing-session" : undefined, }, }, @@ -481,8 +498,8 @@ export function registerBrowserManageCommands( defaultRuntime.log( info( `🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${ - opts.driver === "existing-session" ? "\n driver: existing-session" : "" - }`, + result.userDataDir ? `\n userDataDir: ${shortenHomePath(result.userDataDir)}` : "" + }${opts.driver === "existing-session" ? "\n driver: existing-session" : ""}`, ), ); }); diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 4793ff6bea6..0469952d322 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import JSON5 from "json5"; +import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js"; import { CONFIG_PATH } from "../config/paths.js"; @@ -20,7 +21,6 @@ type ConfigSetParseOpts = { const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"]; const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"]; -const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434"; function isIndexSegment(raw: string): boolean { return /^[0-9]+$/.test(raw); diff --git a/src/cli/dotenv.ts b/src/cli/dotenv.ts new file mode 100644 index 00000000000..b257f40ecfd --- /dev/null +++ b/src/cli/dotenv.ts @@ -0,0 +1,20 @@ +import fs from "node:fs"; +import path from "node:path"; +import dotenv from "dotenv"; +import { resolveStateDir } from "../config/paths.js"; + +export function loadCliDotEnv(opts?: { quiet?: boolean }) { + const quiet = opts?.quiet ?? true; + + // Load from process CWD first (dotenv default). + dotenv.config({ quiet }); + + // Then load the global fallback from the active state dir without overriding + // any env vars that were already set or loaded from CWD. + const globalEnvPath = path.join(resolveStateDir(process.env), ".env"); + if (!fs.existsSync(globalEnvPath)) { + return; + } + + dotenv.config({ quiet, path: globalEnvPath, override: false }); +} diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts index f51a57d7fda..bff91129204 100644 --- a/src/cli/plugin-registry.ts +++ b/src/cli/plugin-registry.ts @@ -1,9 +1,11 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging.js"; +import { + resolveChannelPluginIds, + resolveConfiguredChannelPluginIds, +} from "../plugins/channel-plugin-ids.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginLogger } from "../plugins/types.js"; @@ -25,34 +27,6 @@ function scopeRank(scope: typeof pluginRegistryLoaded): number { } } -function resolveChannelPluginIds(params: { - config: ReturnType; - workspaceDir?: string; - env: NodeJS.ProcessEnv; -}): string[] { - return loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }) - .plugins.filter((plugin) => plugin.channels.length > 0) - .map((plugin) => plugin.id); -} - -function resolveConfiguredChannelPluginIds(params: { - config: ReturnType; - workspaceDir?: string; - env: NodeJS.ProcessEnv; -}): string[] { - const configuredChannelIds = new Set( - listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), - ); - if (configuredChannelIds.size === 0) { - return []; - } - return resolveChannelPluginIds(params).filter((pluginId) => configuredChannelIds.has(pluginId)); -} - export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistryScope }): void { const scope = options?.scope ?? "all"; if (scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) { diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 6af996ed820..aeed204f739 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -14,8 +14,8 @@ vi.mock("./route.js", () => ({ tryRouteCli: tryRouteCliMock, })); -vi.mock("../infra/dotenv.js", () => ({ - loadDotEnv: loadDotEnvMock, +vi.mock("./dotenv.js", () => ({ + loadCliDotEnv: loadDotEnvMock, })); vi.mock("../infra/env.js", () => ({ diff --git a/src/cli/run-main.profile-env.test.ts b/src/cli/run-main.profile-env.test.ts index cd3dde3a93d..fc0d9bddae6 100644 --- a/src/cli/run-main.profile-env.test.ts +++ b/src/cli/run-main.profile-env.test.ts @@ -12,8 +12,8 @@ const dotenvState = vi.hoisted(() => { }; }); -vi.mock("../infra/dotenv.js", () => ({ - loadDotEnv: dotenvState.loadDotEnv, +vi.mock("./dotenv.js", () => ({ + loadCliDotEnv: dotenvState.loadDotEnv, })); vi.mock("../infra/env.js", () => ({ diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 188448a64e4..594b99ae0b3 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -1,12 +1,10 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; -import { loadDotEnv } from "../infra/dotenv.js"; import { normalizeEnv } from "../infra/env.js"; import { formatUncaughtError } from "../infra/errors.js"; import { isMainModule } from "../infra/is-main.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { assertSupportedRuntime } from "../infra/runtime-guard.js"; -import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { enableConsoleCapture } from "../logging.js"; import { getCommandPathWithRootOptions, @@ -14,6 +12,7 @@ import { hasHelpOrVersion, isRootHelpInvocation, } from "./argv.js"; +import { loadCliDotEnv } from "./dotenv.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js"; import { tryRouteCli } from "./route.js"; import { normalizeWindowsArgv } from "./windows-argv.js"; @@ -91,7 +90,7 @@ export async function runCli(argv: string[] = process.argv) { } normalizedArgv = parsedProfile.argv; - loadDotEnv({ quiet: true }); + loadCliDotEnv({ quiet: true }); normalizeEnv(); if (shouldEnsureCliPath(normalizedArgv)) { ensureOpenClawCliOnPath(); @@ -116,6 +115,7 @@ export async function runCli(argv: string[] = process.argv) { const { buildProgram } = await import("./program.js"); const program = buildProgram(); + const { installUnhandledRejectionHandler } = await import("../infra/unhandled-rejections.js"); // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. // These log the error and exit gracefully instead of crashing without trace. diff --git a/src/commands/auth-profile-config.ts b/src/commands/auth-profile-config.ts new file mode 100644 index 00000000000..797135b87b2 --- /dev/null +++ b/src/commands/auth-profile-config.ts @@ -0,0 +1,73 @@ +import type { OpenClawConfig } from "../config/config.js"; + +export function applyAuthProfileConfig( + cfg: OpenClawConfig, + params: { + profileId: string; + provider: string; + mode: "api_key" | "oauth" | "token"; + email?: string; + preferProfileFirst?: boolean; + }, +): OpenClawConfig { + const normalizedProvider = params.provider.toLowerCase(); + const profiles = { + ...cfg.auth?.profiles, + [params.profileId]: { + provider: params.provider, + mode: params.mode, + ...(params.email ? { email: params.email } : {}), + }, + }; + + const configuredProviderProfiles = Object.entries(cfg.auth?.profiles ?? {}) + .filter(([, profile]) => profile.provider.toLowerCase() === normalizedProvider) + .map(([profileId, profile]) => ({ profileId, mode: profile.mode })); + + // Maintain `auth.order` when it already exists. Additionally, if we detect + // mixed auth modes for the same provider (e.g. legacy oauth + newly selected + // api_key), create an explicit order to keep the newly selected profile first. + const existingProviderOrder = cfg.auth?.order?.[params.provider]; + const preferProfileFirst = params.preferProfileFirst ?? true; + const reorderedProviderOrder = + existingProviderOrder && preferProfileFirst + ? [ + params.profileId, + ...existingProviderOrder.filter((profileId) => profileId !== params.profileId), + ] + : existingProviderOrder; + const hasMixedConfiguredModes = configuredProviderProfiles.some( + ({ profileId, mode }) => profileId !== params.profileId && mode !== params.mode, + ); + const derivedProviderOrder = + existingProviderOrder === undefined && preferProfileFirst && hasMixedConfiguredModes + ? [ + params.profileId, + ...configuredProviderProfiles + .map(({ profileId }) => profileId) + .filter((profileId) => profileId !== params.profileId), + ] + : undefined; + const order = + existingProviderOrder !== undefined + ? { + ...cfg.auth?.order, + [params.provider]: reorderedProviderOrder?.includes(params.profileId) + ? reorderedProviderOrder + : [...(reorderedProviderOrder ?? []), params.profileId], + } + : derivedProviderOrder + ? { + ...cfg.auth?.order, + [params.provider]: derivedProviderOrder, + } + : cfg.auth?.order; + return { + ...cfg, + auth: { + ...cfg.auth, + profiles, + ...(order ? { order } : {}), + }, + }; +} diff --git a/src/commands/doctor-browser.test.ts b/src/commands/doctor-browser.test.ts index da59fe5ed9a..948562eaf17 100644 --- a/src/commands/doctor-browser.test.ts +++ b/src/commands/doctor-browser.test.ts @@ -36,7 +36,7 @@ describe("doctor browser readiness", () => { expect(noteFn).toHaveBeenCalledTimes(1); expect(String(noteFn.mock.calls[0]?.[0])).toContain("Google Chrome was not found"); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("chrome://inspect/#remote-debugging"); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("brave://inspect/#remote-debugging"); }); it("warns when detected Chrome is too old for Chrome MCP", async () => { @@ -93,4 +93,31 @@ describe("doctor browser readiness", () => { "Detected Chrome Google Chrome 144.0.7534.0", ); }); + + it("skips Chrome auto-detection when profiles use explicit userDataDir", async () => { + const noteFn = vi.fn(); + await noteChromeMcpBrowserReadiness( + { + browser: { + profiles: { + braveLive: { + driver: "existing-session", + userDataDir: "/Users/test/Library/Application Support/BraveSoftware/Brave-Browser", + color: "#FB542B", + }, + }, + }, + }, + { + noteFn, + resolveChromeExecutable: () => { + throw new Error("should not look up Chrome"); + }, + }, + ); + + expect(noteFn).toHaveBeenCalledTimes(1); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("explicit Chromium user data directory"); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("brave://inspect/#remote-debugging"); + }); }); diff --git a/src/commands/doctor-browser.ts b/src/commands/doctor-browser.ts index 482e370b052..028bfc50fb0 100644 --- a/src/commands/doctor-browser.ts +++ b/src/commands/doctor-browser.ts @@ -7,6 +7,11 @@ import type { OpenClawConfig } from "../config/config.js"; import { note } from "../terminal/note.js"; const CHROME_MCP_MIN_MAJOR = 144; +const REMOTE_DEBUGGING_PAGES = [ + "chrome://inspect/#remote-debugging", + "brave://inspect/#remote-debugging", + "edge://inspect/#remote-debugging", +].join(", "); function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -14,33 +19,40 @@ function asRecord(value: unknown): Record | null { : null; } -function collectChromeMcpProfileNames(cfg: OpenClawConfig): string[] { +type ExistingSessionProfile = { + name: string; + userDataDir?: string; +}; + +function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] { const browser = asRecord(cfg.browser); if (!browser) { return []; } - const names = new Set(); + const profiles = new Map(); const defaultProfile = typeof browser.defaultProfile === "string" ? browser.defaultProfile.trim() : ""; if (defaultProfile === "user") { - names.add("user"); + profiles.set("user", { name: "user" }); } - const profiles = asRecord(browser.profiles); - if (!profiles) { - return [...names]; + const configuredProfiles = asRecord(browser.profiles); + if (!configuredProfiles) { + return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name)); } - for (const [profileName, rawProfile] of Object.entries(profiles)) { + for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) { const profile = asRecord(rawProfile); const driver = typeof profile?.driver === "string" ? profile.driver.trim() : ""; if (driver === "existing-session") { - names.add(profileName); + const userDataDir = + typeof profile?.userDataDir === "string" ? profile.userDataDir.trim() : undefined; + profiles.set(profileName, { name: profileName, userDataDir: userDataDir || undefined }); } } - return [...names].toSorted((a, b) => a.localeCompare(b)); + return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name)); } export async function noteChromeMcpBrowserReadiness( @@ -52,7 +64,7 @@ export async function noteChromeMcpBrowserReadiness( readVersion?: (executablePath: string) => string | null; }, ) { - const profiles = collectChromeMcpProfileNames(cfg); + const profiles = collectChromeMcpProfiles(cfg); if (profiles.length === 0) { return; } @@ -62,24 +74,47 @@ export async function noteChromeMcpBrowserReadiness( const resolveChromeExecutable = deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform; const readVersion = deps?.readVersion ?? readBrowserVersion; - const chrome = resolveChromeExecutable(platform); - const profileLabel = profiles.join(", "); + const explicitProfiles = profiles.filter((profile) => profile.userDataDir); + const autoConnectProfiles = profiles.filter((profile) => !profile.userDataDir); + const profileLabel = profiles.map((profile) => profile.name).join(", "); - if (!chrome) { + if (autoConnectProfiles.length === 0) { noteFn( [ `- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`, - "- Google Chrome was not found on this host. OpenClaw does not bundle Chrome.", - `- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`, - "- In Chrome, enable remote debugging at chrome://inspect/#remote-debugging.", - "- Keep Chrome running and accept the attach consent prompt the first time OpenClaw connects.", - "- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.", + "- These profiles use an explicit Chromium user data directory instead of Chrome's default auto-connect path.", + `- Verify the matching Chromium-based browser is version ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`, + `- Enable remote debugging in that browser's inspect page (${REMOTE_DEBUGGING_PAGES}).`, + "- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.", ].join("\n"), "Browser", ); return; } + const chrome = resolveChromeExecutable(platform); + const autoProfileLabel = autoConnectProfiles.map((profile) => profile.name).join(", "); + + if (!chrome) { + const lines = [ + `- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`, + `- Google Chrome was not found on this host for auto-connect profile(s): ${autoProfileLabel}. OpenClaw does not bundle Chrome.`, + `- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node, or set browser.profiles..userDataDir for a different Chromium-based browser.`, + `- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`, + "- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.", + "- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.", + ]; + if (explicitProfiles.length > 0) { + lines.push( + `- Profiles with explicit userDataDir skip Chrome auto-detection: ${explicitProfiles + .map((profile) => profile.name) + .join(", ")}.`, + ); + } + noteFn(lines.join("\n"), "Browser"); + return; + } + const versionRaw = readVersion(chrome.path); const major = parseBrowserMajorVersion(versionRaw); const lines = [ @@ -99,10 +134,17 @@ export async function noteChromeMcpBrowserReadiness( lines.push(`- Detected Chrome ${versionRaw}.`); } - lines.push("- In Chrome, enable remote debugging at chrome://inspect/#remote-debugging."); + lines.push(`- Enable remote debugging in the browser inspect page (${REMOTE_DEBUGGING_PAGES}).`); lines.push( - "- Keep Chrome running and accept the attach consent prompt the first time OpenClaw connects.", + "- Keep the browser running and accept the attach consent prompt the first time OpenClaw connects.", ); + if (explicitProfiles.length > 0) { + lines.push( + `- Profiles with explicit userDataDir still need manual validation of the matching Chromium-based browser: ${explicitProfiles + .map((profile) => profile.name) + .join(", ")}.`, + ); + } noteFn(lines.join("\n"), "Browser"); } diff --git a/src/commands/ollama-setup.ts b/src/commands/ollama-setup.ts index 060724061bd..4557f606bb6 100644 --- a/src/commands/ollama-setup.ts +++ b/src/commands/ollama-setup.ts @@ -1,6 +1,6 @@ -import { upsertAuthProfileWithLock } from "../agents/auth-profiles.js"; +import { upsertAuthProfileWithLock } from "../agents/auth-profiles/upsert-with-lock.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; import { - OLLAMA_DEFAULT_BASE_URL, buildOllamaModelDefinition, enrichOllamaModelsWithContext, fetchOllamaModels, @@ -15,7 +15,7 @@ import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; import { openUrl } from "./onboard-helpers.js"; import type { OnboardMode, OnboardOptions } from "./onboard-types.js"; -export { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-models.js"; +export { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; export const OLLAMA_DEFAULT_MODEL = "glm-4.7-flash"; const OLLAMA_SUGGESTED_MODELS_LOCAL = ["glm-4.7-flash"]; diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 0afd59c3910..c939a2cb99d 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -84,6 +84,7 @@ import { MODELSTUDIO_GLOBAL_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, } from "./onboard-auth.models.js"; +export { applyAuthProfileConfig } from "./auth-profile-config.js"; function mergeProviderModels( existingProvider: Record | undefined, @@ -484,78 +485,6 @@ export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { return applyAgentDefaultModelPrimary(next, KILOCODE_DEFAULT_MODEL_REF); } -export function applyAuthProfileConfig( - cfg: OpenClawConfig, - params: { - profileId: string; - provider: string; - mode: "api_key" | "oauth" | "token"; - email?: string; - preferProfileFirst?: boolean; - }, -): OpenClawConfig { - const normalizedProvider = params.provider.toLowerCase(); - const profiles = { - ...cfg.auth?.profiles, - [params.profileId]: { - provider: params.provider, - mode: params.mode, - ...(params.email ? { email: params.email } : {}), - }, - }; - - const configuredProviderProfiles = Object.entries(cfg.auth?.profiles ?? {}) - .filter(([, profile]) => profile.provider.toLowerCase() === normalizedProvider) - .map(([profileId, profile]) => ({ profileId, mode: profile.mode })); - - // Maintain `auth.order` when it already exists. Additionally, if we detect - // mixed auth modes for the same provider (e.g. legacy oauth + newly selected - // api_key), create an explicit order to keep the newly selected profile first. - const existingProviderOrder = cfg.auth?.order?.[params.provider]; - const preferProfileFirst = params.preferProfileFirst ?? true; - const reorderedProviderOrder = - existingProviderOrder && preferProfileFirst - ? [ - params.profileId, - ...existingProviderOrder.filter((profileId) => profileId !== params.profileId), - ] - : existingProviderOrder; - const hasMixedConfiguredModes = configuredProviderProfiles.some( - ({ profileId, mode }) => profileId !== params.profileId && mode !== params.mode, - ); - const derivedProviderOrder = - existingProviderOrder === undefined && preferProfileFirst && hasMixedConfiguredModes - ? [ - params.profileId, - ...configuredProviderProfiles - .map(({ profileId }) => profileId) - .filter((profileId) => profileId !== params.profileId), - ] - : undefined; - const order = - existingProviderOrder !== undefined - ? { - ...cfg.auth?.order, - [params.provider]: reorderedProviderOrder?.includes(params.profileId) - ? reorderedProviderOrder - : [...(reorderedProviderOrder ?? []), params.profileId], - } - : derivedProviderOrder - ? { - ...cfg.auth?.order, - [params.provider]: derivedProviderOrder, - } - : cfg.auth?.order; - return { - ...cfg, - auth: { - ...cfg.auth, - profiles, - ...(order ? { order } : {}), - }, - }; -} - export function applyQianfanProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; models[QIANFAN_DEFAULT_MODEL_REF] = { diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index 6d78766853a..cf86da64211 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js"; -import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-models.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; import type { OpenClawConfig } from "../config/config.js"; import { defaultRuntime } from "../runtime.js"; import { diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index 874018a74ea..9de8e3f85cf 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -1,7 +1,7 @@ import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js"; import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { buildModelAliasIndex, modelKey } from "../agents/model-selection.js"; -import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-models.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderConfig } from "../config/types.models.js"; import { isSecretRef, type SecretInput } from "../config/types.secrets.js"; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts index a19d1861c7e..a02dd2f2ee2 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts @@ -1,2 +1,5 @@ export { resolveProviderPluginChoice } from "../../../plugins/provider-wizard.js"; -export { resolvePluginProviders } from "../../../plugins/providers.js"; +export { + resolveOwningPluginIdsForProvider, + resolvePluginProviders, +} from "../../../plugins/providers.js"; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts index 4e0f37e2882..f993091dd49 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts @@ -7,9 +7,11 @@ vi.mock("../../auth-choice.preferred-provider.js", () => ({ resolvePreferredProviderForAuthChoice, })); +const resolveOwningPluginIdsForProvider = vi.hoisted(() => vi.fn(() => undefined)); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn()); const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); vi.mock("./auth-choice.plugin-providers.runtime.js", () => ({ + resolveOwningPluginIdsForProvider, resolveProviderPluginChoice, resolvePluginProviders, PROVIDER_PLUGIN_CHOICE_PREFIX: "provider-plugin:", @@ -30,6 +32,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { it("loads plugin providers for provider-plugin auth choices", async () => { const runtime = createRuntime(); const runNonInteractive = vi.fn(async () => ({ plugins: { allow: ["vllm"] } })); + resolveOwningPluginIdsForProvider.mockReturnValue(["vllm"] as never); resolvePluginProviders.mockReturnValue([{ id: "vllm", pluginId: "vllm" }] as never); resolveProviderPluginChoice.mockReturnValue({ provider: { id: "vllm", pluginId: "vllm", label: "vLLM" }, @@ -46,7 +49,18 @@ describe("applyNonInteractivePluginProviderChoice", () => { toApiKeyCredential: vi.fn(), }); + expect(resolveOwningPluginIdsForProvider).toHaveBeenCalledOnce(); + expect(resolveOwningPluginIdsForProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "vllm", + }), + ); expect(resolvePluginProviders).toHaveBeenCalledOnce(); + expect(resolvePluginProviders).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["vllm"], + }), + ); expect(resolveProviderPluginChoice).toHaveBeenCalledOnce(); expect(runNonInteractive).toHaveBeenCalledOnce(); expect(result).toEqual({ plugins: { allow: ["vllm"] } }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index 8d9b820fc52..3f11a7367a9 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -79,11 +79,20 @@ export async function applyNonInteractivePluginProviderChoice(params: { params.nextConfig, preferredProviderId, ); - const { resolveProviderPluginChoice, resolvePluginProviders } = await loadPluginProviderRuntime(); + const { resolveOwningPluginIdsForProvider, resolveProviderPluginChoice, resolvePluginProviders } = + await loadPluginProviderRuntime(); + const owningPluginIds = preferredProviderId + ? resolveOwningPluginIdsForProvider({ + provider: preferredProviderId, + config: resolutionConfig, + workspaceDir, + }) + : undefined; const providerChoice = resolveProviderPluginChoice({ providers: resolvePluginProviders({ config: resolutionConfig, workspaceDir, + onlyPluginIds: owningPluginIds, bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }), diff --git a/src/commands/self-hosted-provider-setup.ts b/src/commands/self-hosted-provider-setup.ts index c067d797f15..ec2d8c683e3 100644 --- a/src/commands/self-hosted-provider-setup.ts +++ b/src/commands/self-hosted-provider-setup.ts @@ -1,5 +1,10 @@ -import { upsertAuthProfileWithLock } from "../agents/auth-profiles.js"; import type { ApiKeyCredential, AuthProfileCredential } from "../agents/auth-profiles/types.js"; +import { upsertAuthProfileWithLock } from "../agents/auth-profiles/upsert-with-lock.js"; +import { + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "../agents/self-hosted-provider-defaults.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ProviderDiscoveryContext, @@ -8,16 +13,13 @@ import type { ProviderNonInteractiveApiKeyResult, } from "../plugins/types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -import { applyAuthProfileConfig } from "./onboard-auth.js"; +import { applyAuthProfileConfig } from "./auth-profile-config.js"; -export const SELF_HOSTED_DEFAULT_CONTEXT_WINDOW = 128000; -export const SELF_HOSTED_DEFAULT_MAX_TOKENS = 8192; -export const SELF_HOSTED_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; +export { + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "../agents/self-hosted-provider-defaults.js"; export function applyProviderDefaultModel(cfg: OpenClawConfig, modelRef: string): OpenClawConfig { const existingModel = cfg.agents?.defaults?.model; diff --git a/src/commands/status-json.ts b/src/commands/status-json.ts index 2579717679c..e9221226665 100644 --- a/src/commands/status-json.ts +++ b/src/commands/status-json.ts @@ -1,12 +1,12 @@ -import { callGateway } from "../gateway/call.js"; import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; import type { RuntimeEnv } from "../runtime.js"; import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; -import { scanStatus } from "./status.scan.js"; +import { scanStatusJsonFast } from "./status.scan.fast-json.js"; let providerUsagePromise: Promise | undefined; let securityAuditModulePromise: Promise | undefined; +let gatewayCallModulePromise: Promise | undefined; function loadProviderUsage() { providerUsagePromise ??= import("../infra/provider-usage.js"); @@ -18,6 +18,11 @@ function loadSecurityAuditModule() { return securityAuditModulePromise; } +function loadGatewayCallModule() { + gatewayCallModulePromise ??= import("../gateway/call.js"); + return gatewayCallModulePromise; +} + export async function statusJsonCommand( opts: { deep?: boolean; @@ -27,7 +32,7 @@ export async function statusJsonCommand( }, runtime: RuntimeEnv, ) { - const scan = await scanStatus({ json: true, timeoutMs: opts.timeoutMs, all: opts.all }, runtime); + const scan = await scanStatusJsonFast({ timeoutMs: opts.timeoutMs, all: opts.all }, runtime); const securityAudit = await loadSecurityAuditModule().then(({ runSecurityAudit }) => runSecurityAudit({ config: scan.cfg, @@ -43,17 +48,21 @@ export async function statusJsonCommand( loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }), ) : undefined; - const health = opts.deep - ? await callGateway({ - method: "health", - params: { probe: true }, - timeoutMs: opts.timeoutMs, - config: scan.cfg, - }).catch(() => undefined) - : undefined; + const gatewayCall = opts.deep + ? await loadGatewayCallModule().then((mod) => mod.callGateway) + : null; + const health = + gatewayCall != null + ? await gatewayCall({ + method: "health", + params: { probe: true }, + timeoutMs: opts.timeoutMs, + config: scan.cfg, + }).catch(() => undefined) + : undefined; const lastHeartbeat = - opts.deep && scan.gatewayReachable - ? await callGateway({ + gatewayCall != null && scan.gatewayReachable + ? await gatewayCall({ method: "last-heartbeat", params: {}, timeoutMs: opts.timeoutMs, diff --git a/src/commands/status.agent-local.ts b/src/commands/status.agent-local.ts index ce17f9ab94f..1a29ab45a46 100644 --- a/src/commands/status.agent-local.ts +++ b/src/commands/status.agent-local.ts @@ -1,9 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { loadConfig } from "../config/config.js"; -import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { readSessionStoreReadOnly } from "../config/sessions/store-read.js"; +import type { OpenClawConfig } from "../config/types.js"; import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; export type AgentLocalStatus = { @@ -34,7 +34,7 @@ async function fileExists(p: string): Promise { } export async function getAgentLocalStatuses( - cfg: OpenClawConfig = loadConfig(), + cfg: OpenClawConfig, ): Promise { const agentList = listGatewayAgentsBasic(cfg); const now = Date.now(); @@ -54,13 +54,7 @@ export async function getAgentLocalStatuses( const bootstrapPending = bootstrapPath != null ? await fileExists(bootstrapPath) : null; const sessionsPath = resolveStorePath(cfg.session?.store, { agentId }); - const store = (() => { - try { - return loadSessionStore(sessionsPath); - } catch { - return {}; - } - })(); + const store = readSessionStoreReadOnly(sessionsPath); const sessions = Object.entries(store) .filter(([key]) => key !== "global" && key !== "unknown") .map(([, entry]) => entry); diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts new file mode 100644 index 00000000000..505084ef992 --- /dev/null +++ b/src/commands/status.scan.fast-json.ts @@ -0,0 +1,419 @@ +import { existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; +import { resolveConfigPath, resolveGatewayPort, resolveStateDir } from "../config/paths.js"; +import type { OpenClawConfig } from "../config/types.js"; +import { isSecureWebSocketUrl } from "../gateway/net.js"; +import { probeGateway } from "../gateway/probe.js"; +import { resolveOsSummary } from "../infra/os-summary.js"; +import type { MemoryProviderStatus } from "../memory/types.js"; +import { runExec } from "../process/exec.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { getAgentLocalStatuses } from "./status.agent-local.js"; +import { + pickGatewaySelfPresence, + resolveGatewayProbeAuthResolution, +} from "./status.gateway-probe.js"; +import type { StatusScanResult } from "./status.scan.js"; +import { getStatusSummary } from "./status.summary.js"; +import { getUpdateCheckResult } from "./status.update.js"; + +type MemoryStatusSnapshot = MemoryProviderStatus & { + agentId: string; +}; + +type MemoryPluginStatus = { + enabled: boolean; + slot: string | null; + reason?: string; +}; + +type GatewayConnectionDetails = { + url: string; + urlSource: string; + bindDetail?: string; + remoteFallbackNote?: string; + message: string; +}; + +type GatewayProbeSnapshot = { + gatewayConnection: GatewayConnectionDetails; + remoteUrlMissing: boolean; + gatewayMode: "local" | "remote"; + gatewayProbeAuth: { + token?: string; + password?: string; + }; + gatewayProbeAuthWarning?: string; + gatewayProbe: Awaited> | null; +}; + +let pluginRegistryModulePromise: Promise | undefined; +let configIoModulePromise: Promise | undefined; +let commandSecretTargetsModulePromise: + | Promise + | undefined; +let commandSecretGatewayModulePromise: + | Promise + | undefined; +let memorySearchModulePromise: Promise | undefined; +let statusScanDepsRuntimeModulePromise: + | Promise + | undefined; + +function loadPluginRegistryModule() { + pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); + return pluginRegistryModulePromise; +} + +function loadConfigIoModule() { + configIoModulePromise ??= import("../config/io.js"); + return configIoModulePromise; +} + +function loadCommandSecretTargetsModule() { + commandSecretTargetsModulePromise ??= import("../cli/command-secret-targets.js"); + return commandSecretTargetsModulePromise; +} + +function loadCommandSecretGatewayModule() { + commandSecretGatewayModulePromise ??= import("../cli/command-secret-gateway.js"); + return commandSecretGatewayModulePromise; +} + +function loadMemorySearchModule() { + memorySearchModulePromise ??= import("../agents/memory-search.js"); + return memorySearchModulePromise; +} + +function loadStatusScanDepsRuntimeModule() { + statusScanDepsRuntimeModulePromise ??= import("./status.scan.deps.runtime.js"); + return statusScanDepsRuntimeModulePromise; +} + +function shouldSkipMissingConfigFastPath(): boolean { + return ( + process.env.VITEST === "true" || + process.env.VITEST_POOL_ID !== undefined || + process.env.NODE_ENV === "test" + ); +} + +function hasExplicitMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean { + if ( + cfg.agents?.defaults && + Object.prototype.hasOwnProperty.call(cfg.agents.defaults, "memorySearch") + ) { + return true; + } + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + return agents.some( + (agent) => agent?.id === agentId && Object.prototype.hasOwnProperty.call(agent, "memorySearch"), + ); +} + +function normalizeControlUiBasePath(basePath?: string): string { + if (!basePath) { + return ""; + } + let normalized = basePath.trim(); + if (!normalized) { + return ""; + } + if (!normalized.startsWith("/")) { + normalized = `/${normalized}`; + } + if (normalized === "/") { + return ""; + } + if (normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +function trimToUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function buildGatewayConnectionDetails(options: { + config: OpenClawConfig; + url?: string; + configPath?: string; + urlSource?: "cli" | "env"; +}): GatewayConnectionDetails { + const config = options.config; + const configPath = + options.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)); + const isRemoteMode = config.gateway?.mode === "remote"; + const remote = isRemoteMode ? config.gateway?.remote : undefined; + const tlsEnabled = config.gateway?.tls?.enabled === true; + const localPort = resolveGatewayPort(config); + const bindMode = config.gateway?.bind ?? "loopback"; + const scheme = tlsEnabled ? "wss" : "ws"; + const localUrl = `${scheme}://127.0.0.1:${localPort}`; + const cliUrlOverride = + typeof options.url === "string" && options.url.trim().length > 0 + ? options.url.trim() + : undefined; + const envUrlOverride = cliUrlOverride + ? undefined + : (trimToUndefined(process.env.OPENCLAW_GATEWAY_URL) ?? + trimToUndefined(process.env.CLAWDBOT_GATEWAY_URL)); + const urlOverride = cliUrlOverride ?? envUrlOverride; + const remoteUrl = + typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined; + const remoteMisconfigured = isRemoteMode && !urlOverride && !remoteUrl; + const urlSourceHint = + options.urlSource ?? (cliUrlOverride ? "cli" : envUrlOverride ? "env" : undefined); + const url = urlOverride || remoteUrl || localUrl; + const urlSource = urlOverride + ? urlSourceHint === "env" + ? "env OPENCLAW_GATEWAY_URL" + : "cli --url" + : remoteUrl + ? "config gateway.remote.url" + : remoteMisconfigured + ? "missing gateway.remote.url (fallback local)" + : "local loopback"; + const bindDetail = !urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined; + const remoteFallbackNote = remoteMisconfigured + ? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local." + : undefined; + const allowPrivateWs = process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1"; + if (!isSecureWebSocketUrl(url, { allowPrivateWs })) { + throw new Error( + [ + `SECURITY ERROR: Gateway URL "${url}" uses plaintext ws:// to a non-loopback address.`, + "Both credentials and chat data would be exposed to network interception.", + `Source: ${urlSource}`, + `Config: ${configPath}`, + ].join("\n"), + ); + } + return { + url, + urlSource, + bindDetail, + remoteFallbackNote, + message: [ + `Gateway target: ${url}`, + `Source: ${urlSource}`, + `Config: ${configPath}`, + bindDetail, + remoteFallbackNote, + ] + .filter(Boolean) + .join("\n"), + }; +} + +function resolveDefaultMemoryStorePath(agentId: string): string { + return path.join(resolveStateDir(process.env, os.homedir), "memory", `${agentId}.sqlite`); +} + +function resolveMemoryPluginStatus(cfg: OpenClawConfig): MemoryPluginStatus { + const pluginsEnabled = cfg.plugins?.enabled !== false; + if (!pluginsEnabled) { + return { enabled: false, slot: null, reason: "plugins disabled" }; + } + const raw = typeof cfg.plugins?.slots?.memory === "string" ? cfg.plugins.slots.memory.trim() : ""; + if (raw && raw.toLowerCase() === "none") { + return { enabled: false, slot: null, reason: 'plugins.slots.memory="none"' }; + } + return { enabled: true, slot: raw || "memory-core" }; +} + +async function resolveGatewayProbeSnapshot(params: { + cfg: OpenClawConfig; + opts: { timeoutMs?: number; all?: boolean }; +}): Promise { + const gatewayConnection = buildGatewayConnectionDetails({ config: params.cfg }); + const isRemoteMode = params.cfg.gateway?.mode === "remote"; + const remoteUrlRaw = + typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url : ""; + const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim(); + const gatewayMode = isRemoteMode ? "remote" : "local"; + const gatewayProbeAuthResolution = resolveGatewayProbeAuthResolution(params.cfg); + let gatewayProbeAuthWarning = gatewayProbeAuthResolution.warning; + const gatewayProbe = remoteUrlMissing + ? null + : await probeGateway({ + url: gatewayConnection.url, + auth: gatewayProbeAuthResolution.auth, + timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000), + detailLevel: "presence", + }).catch(() => null); + if (gatewayProbeAuthWarning && gatewayProbe?.ok === false) { + gatewayProbe.error = gatewayProbe.error + ? `${gatewayProbe.error}; ${gatewayProbeAuthWarning}` + : gatewayProbeAuthWarning; + gatewayProbeAuthWarning = undefined; + } + return { + gatewayConnection, + remoteUrlMissing, + gatewayMode, + gatewayProbeAuth: gatewayProbeAuthResolution.auth, + gatewayProbeAuthWarning, + gatewayProbe, + }; +} + +async function resolveMemoryStatusSnapshot(params: { + cfg: OpenClawConfig; + agentStatus: Awaited>; + memoryPlugin: MemoryPluginStatus; +}): Promise { + const { cfg, agentStatus, memoryPlugin } = params; + if (!memoryPlugin.enabled || memoryPlugin.slot !== "memory-core") { + return null; + } + const agentId = agentStatus.defaultId ?? "main"; + const explicitMemoryConfig = hasExplicitMemorySearchConfig(cfg, agentId); + const defaultStorePath = resolveDefaultMemoryStorePath(agentId); + if (!explicitMemoryConfig && !existsSync(defaultStorePath)) { + return null; + } + const { resolveMemorySearchConfig } = await loadMemorySearchModule(); + const resolvedMemory = resolveMemorySearchConfig(cfg, agentId); + if (!resolvedMemory) { + return null; + } + const shouldInspectStore = + hasExplicitMemorySearchConfig(cfg, agentId) || existsSync(resolvedMemory.store.path); + if (!shouldInspectStore) { + return null; + } + const { getMemorySearchManager } = await loadStatusScanDepsRuntimeModule(); + const { manager } = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + if (!manager) { + return null; + } + try { + await manager.probeVectorAvailability(); + } catch {} + const status = manager.status(); + await manager.close?.().catch(() => {}); + return { agentId, ...status }; +} + +async function readStatusSourceConfig(): Promise { + if (!shouldSkipMissingConfigFastPath() && !existsSync(resolveConfigPath(process.env))) { + return {}; + } + const { readBestEffortConfig } = await loadConfigIoModule(); + return await readBestEffortConfig(); +} + +async function resolveStatusConfig(params: { + sourceConfig: OpenClawConfig; + commandName: "status --json"; +}): Promise<{ resolvedConfig: OpenClawConfig; diagnostics: string[] }> { + if (!shouldSkipMissingConfigFastPath() && !existsSync(resolveConfigPath(process.env))) { + return { resolvedConfig: params.sourceConfig, diagnostics: [] }; + } + const [{ resolveCommandSecretRefsViaGateway }, { getStatusCommandSecretTargetIds }] = + await Promise.all([loadCommandSecretGatewayModule(), loadCommandSecretTargetsModule()]); + return await resolveCommandSecretRefsViaGateway({ + config: params.sourceConfig, + commandName: params.commandName, + targetIds: getStatusCommandSecretTargetIds(), + mode: "read_only_status", + }); +} + +export async function scanStatusJsonFast( + opts: { + timeoutMs?: number; + all?: boolean; + }, + _runtime: RuntimeEnv, +): Promise { + const loadedRaw = await readStatusSourceConfig(); + const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = await resolveStatusConfig({ + sourceConfig: loadedRaw, + commandName: "status --json", + }); + if (hasPotentialConfiguredChannels(cfg)) { + const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + } + const osSummary = resolveOsSummary(); + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + const updateTimeoutMs = opts.all ? 6500 : 2500; + const updatePromise = getUpdateCheckResult({ + timeoutMs: updateTimeoutMs, + fetchGit: true, + includeRegistry: true, + }); + const agentStatusPromise = getAgentLocalStatuses(cfg); + const summaryPromise = getStatusSummary({ config: cfg, sourceConfig: loadedRaw }); + + const tailscaleDnsPromise = + tailscaleMode === "off" + ? Promise.resolve(null) + : loadStatusScanDepsRuntimeModule() + .then(({ getTailnetHostname }) => + getTailnetHostname((cmd, args) => + runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), + ), + ) + .catch(() => null); + + const gatewayProbePromise = resolveGatewayProbeSnapshot({ cfg, opts }); + + const [tailscaleDns, update, agentStatus, gatewaySnapshot, summary] = await Promise.all([ + tailscaleDnsPromise, + updatePromise, + agentStatusPromise, + gatewayProbePromise, + summaryPromise, + ]); + const tailscaleHttpsUrl = + tailscaleMode !== "off" && tailscaleDns + ? `https://${tailscaleDns}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}` + : null; + + const { + gatewayConnection, + remoteUrlMissing, + gatewayMode, + gatewayProbeAuth, + gatewayProbeAuthWarning, + gatewayProbe, + } = gatewaySnapshot; + const gatewayReachable = gatewayProbe?.ok === true; + const gatewaySelf = gatewayProbe?.presence + ? pickGatewaySelfPresence(gatewayProbe.presence) + : null; + const memoryPlugin = resolveMemoryPluginStatus(cfg); + const memory = await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); + + return { + cfg, + sourceConfig: loadedRaw, + secretDiagnostics, + osSummary, + tailscaleMode, + tailscaleDns, + tailscaleHttpsUrl, + update, + gatewayConnection, + remoteUrlMissing, + gatewayMode, + gatewayProbeAuth, + gatewayProbeAuthWarning, + gatewayProbe, + gatewayReachable, + gatewaySelf, + channelIssues: [], + agentStatus, + channels: { rows: [], details: [] }, + summary, + memory, + memoryPlugin, + }; +} diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 6e778070c09..edb77ae4fcf 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ getUpdateCheckResult: vi.fn(), getAgentLocalStatuses: vi.fn(), getStatusSummary: vi.fn(), + getMemorySearchManager: vi.fn(), buildGatewayConnectionDetails: vi.fn(), probeGateway: vi.fn(), resolveGatewayProbeAuthResolution: vi.fn(), @@ -53,7 +54,7 @@ vi.mock("../infra/os-summary.js", () => ({ vi.mock("./status.scan.deps.runtime.js", () => ({ getTailnetHostname: vi.fn(), - getMemorySearchManager: vi.fn(), + getMemorySearchManager: mocks.getMemorySearchManager, })); vi.mock("../gateway/call.js", () => ({ @@ -196,6 +197,141 @@ describe("scanStatus", () => { expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); }); + it("skips memory backend inspection for default memory-core with no existing store", async () => { + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + gateway: {}, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + gateway: {}, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: undefined, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await scanStatus({ json: true }, {} as never); + + expect(mocks.getMemorySearchManager).not.toHaveBeenCalled(); + }); + + it("inspects memory backend when memory search is explicitly configured", async () => { + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + gateway: {}, + agents: { + defaults: { + memorySearch: { + provider: "local", + local: { modelPath: "/tmp/model.gguf" }, + fallback: "none", + }, + }, + }, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + gateway: {}, + agents: { + defaults: { + memorySearch: { + provider: "local", + local: { modelPath: "/tmp/model.gguf" }, + fallback: "none", + }, + }, + }, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: undefined, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + mocks.getMemorySearchManager.mockResolvedValue({ + manager: { + probeVectorAvailability: vi.fn(async () => true), + status: vi.fn(() => ({ files: 0, chunks: 0, dirty: false })), + close: vi.fn(async () => {}), + }, + }); + + await scanStatus({ json: true }, {} as never); + + expect(mocks.getMemorySearchManager).toHaveBeenCalledWith({ + cfg: expect.objectContaining({ + agents: expect.objectContaining({ + defaults: expect.objectContaining({ + memorySearch: expect.any(Object), + }), + }), + }), + agentId: "main", + purpose: "status", + }); + }); + it("preloads configured channel plugins for status --json when channel config exists", async () => { mocks.readBestEffortConfig.mockResolvedValue({ session: {}, diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index bbe10301624..6c2bd67f3dd 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js"; @@ -33,6 +35,19 @@ type MemoryPluginStatus = { reason?: string; }; +function hasExplicitMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean { + if ( + cfg.agents?.defaults && + Object.prototype.hasOwnProperty.call(cfg.agents.defaults, "memorySearch") + ) { + return true; + } + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + return agents.some( + (agent) => agent?.id === agentId && Object.prototype.hasOwnProperty.call(agent, "memorySearch"), + ); +} + type DeferredResult = { ok: true; value: T } | { ok: false; error: unknown }; type GatewayProbeSnapshot = { @@ -190,6 +205,15 @@ async function resolveMemoryStatusSnapshot(params: { return null; } const agentId = agentStatus.defaultId ?? "main"; + const resolvedMemory = resolveMemorySearchConfig(cfg, agentId); + if (!resolvedMemory) { + return null; + } + const shouldInspectStore = + hasExplicitMemorySearchConfig(cfg, agentId) || existsSync(resolvedMemory.store.path); + if (!shouldInspectStore) { + return null; + } const { getMemorySearchManager } = await loadStatusScanDepsRuntimeModule(); const { manager } = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); if (!manager) { diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index bc2c7b4c205..c5c3f174547 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -1,15 +1,11 @@ import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { loadConfig } from "../config/config.js"; -import { - loadSessionStore, - resolveFreshSessionTotalTokens, - resolveMainSessionKey, - resolveStorePath, - type SessionEntry, -} from "../config/sessions.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import { resolveMainSessionKey } from "../config/sessions/main-session.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { readSessionStoreReadOnly } from "../config/sessions/store-read.js"; +import { resolveFreshSessionTotalTokens, type SessionEntry } from "../config/sessions/types.js"; +import type { OpenClawConfig } from "../config/types.js"; import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; @@ -22,6 +18,7 @@ let linkChannelModulePromise: Promise let statusSummaryRuntimeModulePromise: | Promise | undefined; +let configIoModulePromise: Promise | undefined; function loadChannelSummaryModule() { channelSummaryModulePromise ??= import("../infra/channel-summary.js"); @@ -38,6 +35,83 @@ function loadStatusSummaryRuntimeModule() { return statusSummaryRuntimeModulePromise; } +function loadConfigIoModule() { + configIoModulePromise ??= import("../config/io.js"); + return configIoModulePromise; +} + +function parseStatusModelRef( + raw: string, + defaultProvider: string, +): { provider: string; model: string } | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const slash = trimmed.indexOf("/"); + if (slash === -1) { + return { provider: defaultProvider, model: trimmed }; + } + const provider = trimmed.slice(0, slash).trim(); + const model = trimmed.slice(slash + 1).trim(); + if (!provider || !model) { + return null; + } + return { provider, model }; +} + +function resolveConfiguredStatusModelRef(params: { + cfg: OpenClawConfig; + defaultProvider: string; + defaultModel: string; +}): { provider: string; model: string } { + const rawModel = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model) ?? ""; + if (rawModel) { + const trimmed = rawModel.trim(); + const configuredModels = params.cfg.agents?.defaults?.models ?? {}; + if (!trimmed.includes("/")) { + const aliasKey = trimmed.toLowerCase(); + for (const [modelKey, entry] of Object.entries(configuredModels)) { + const aliasValue = (entry as { alias?: unknown } | undefined)?.alias; + const alias = typeof aliasValue === "string" ? aliasValue.trim() : ""; + if (!alias || alias.toLowerCase() !== aliasKey) { + continue; + } + const parsed = parseStatusModelRef(modelKey, params.defaultProvider); + if (parsed) { + return parsed; + } + } + return { provider: "anthropic", model: trimmed }; + } + const parsed = parseStatusModelRef(trimmed, params.defaultProvider); + if (parsed) { + return parsed; + } + } + + const configuredProviders = params.cfg.models?.providers; + if (configuredProviders && typeof configuredProviders === "object") { + const hasDefaultProvider = Boolean(configuredProviders[params.defaultProvider]); + if (!hasDefaultProvider) { + const availableProvider = Object.entries(configuredProviders).find( + ([, providerCfg]) => + providerCfg && + Array.isArray(providerCfg.models) && + providerCfg.models.length > 0 && + providerCfg.models[0]?.id, + ); + if (availableProvider) { + const [providerName, providerCfg] = availableProvider; + const firstModel = providerCfg.models[0]; + return { provider: providerName, model: firstModel.id }; + } + } + } + + return { provider: params.defaultProvider, model: params.defaultModel }; +} + const buildFlags = (entry?: SessionEntry): string[] => { if (!entry) { return []; @@ -105,7 +179,7 @@ export async function getStatusSummary( const { includeSensitive = true } = options; const { classifySessionKey, resolveContextTokensForModel, resolveSessionModelRef } = await loadStatusSummaryRuntimeModule(); - const cfg = options.config ?? loadConfig(); + const cfg = options.config ?? (await loadConfigIoModule()).loadConfig(); const needsChannelPlugins = hasPotentialConfiguredChannels(cfg); const linkContext = needsChannelPlugins ? await loadLinkChannelModule().then(({ resolveLinkChannelContext }) => @@ -134,7 +208,7 @@ export async function getStatusSummary( const mainSessionKey = resolveMainSessionKey(cfg); const queuedSystemEvents = peekSystemEvents(mainSessionKey); - const resolved = resolveConfiguredModelRef({ + const resolved = resolveConfiguredStatusModelRef({ cfg, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, @@ -156,7 +230,7 @@ export async function getStatusSummary( if (cached) { return cached; } - const store = loadSessionStore(storePath); + const store = readSessionStoreReadOnly(storePath); storeCache.set(storePath, store); return store; }; diff --git a/src/commands/vllm-setup.ts b/src/commands/vllm-setup.ts index 4d8657306e6..4c44587c06e 100644 --- a/src/commands/vllm-setup.ts +++ b/src/commands/vllm-setup.ts @@ -1,14 +1,20 @@ +import { + VLLM_DEFAULT_API_KEY_ENV_VAR, + VLLM_DEFAULT_BASE_URL, + VLLM_MODEL_PLACEHOLDER, + VLLM_PROVIDER_LABEL, +} from "../agents/vllm-defaults.js"; import type { OpenClawConfig } from "../config/config.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyProviderDefaultModel, - promptAndConfigureOpenAICompatibleSelfHostedProvider, SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_COST, SELF_HOSTED_DEFAULT_MAX_TOKENS, + promptAndConfigureOpenAICompatibleSelfHostedProvider, } from "./self-hosted-provider-setup.js"; -export const VLLM_DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1"; +export { VLLM_DEFAULT_BASE_URL } from "../agents/vllm-defaults.js"; export const VLLM_DEFAULT_CONTEXT_WINDOW = SELF_HOSTED_DEFAULT_CONTEXT_WINDOW; export const VLLM_DEFAULT_MAX_TOKENS = SELF_HOSTED_DEFAULT_MAX_TOKENS; export const VLLM_DEFAULT_COST = SELF_HOSTED_DEFAULT_COST; @@ -21,10 +27,10 @@ export async function promptAndConfigureVllm(params: { cfg: params.cfg, prompter: params.prompter, providerId: "vllm", - providerLabel: "vLLM", + providerLabel: VLLM_PROVIDER_LABEL, defaultBaseUrl: VLLM_DEFAULT_BASE_URL, - defaultApiKeyEnvVar: "VLLM_API_KEY", - modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct", + defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: VLLM_MODEL_PLACEHOLDER, }); return { config: result.config, diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index 8439a2768ec..b3dc0c98eb2 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -205,6 +205,19 @@ describe("applyPluginAutoEnable", () => { expect(result.changes).toEqual([]); }); + it("does not auto-enable plugin channels when only enabled=false is set", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { matrix: { enabled: false } }, + }, + env: {}, + manifestRegistry: makeRegistry([{ id: "matrix", channels: ["matrix"] }]), + }); + + expect(result.config.plugins?.entries?.matrix).toBeUndefined(); + expect(result.changes).toEqual([]); + }); + it("auto-enables irc when configured via env", () => { const result = applyPluginAutoEnable({ config: {}, @@ -276,8 +289,8 @@ describe("applyPluginAutoEnable", () => { const result = applyPluginAutoEnable({ config: { channels: { - "env-primary": { enabled: true }, - "env-secondary": { enabled: true }, + "env-primary": { token: "primary" }, + "env-secondary": { token: "secondary" }, }, }, env: { diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 7ca736ee448..c1297e7de4c 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -1,4 +1,5 @@ import { normalizeProviderId } from "../agents/model-selection.js"; +import { hasMeaningfulChannelConfig } from "../channels/config-presence.js"; import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries, @@ -38,10 +39,6 @@ function hasNonEmptyString(value: unknown): boolean { return typeof value === "string" && value.trim().length > 0; } -function recordHasKeys(value: unknown): boolean { - return isRecord(value) && Object.keys(value).length > 0; -} - function accountsHaveKeys(value: unknown, keys: readonly string[]): boolean { if (!isRecord(value)) { return false; @@ -159,7 +156,7 @@ function isStructuredChannelConfigured( if (spec.accountStringKeys && accountsHaveKeys(entry.accounts, spec.accountStringKeys)) { return true; } - return recordHasKeys(entry); + return hasMeaningfulChannelConfig(entry); } function isWhatsAppConfigured(cfg: OpenClawConfig): boolean { @@ -170,12 +167,12 @@ function isWhatsAppConfigured(cfg: OpenClawConfig): boolean { if (!entry) { return false; } - return recordHasKeys(entry); + return hasMeaningfulChannelConfig(entry); } function isGenericChannelConfigured(cfg: OpenClawConfig, channelId: string): boolean { const entry = resolveChannelConfig(cfg, channelId); - return recordHasKeys(entry); + return hasMeaningfulChannelConfig(entry); } export function isChannelConfigured( diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 2680013a717..e915350ee62 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -271,6 +271,7 @@ const TARGET_KEYS = [ "browser.headless", "browser.noSandbox", "browser.profiles", + "browser.profiles.*.userDataDir", "browser.profiles.*.driver", "browser.profiles.*.attachOnly", "tools", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 627dccb5049..1b048bc9aa1 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -260,8 +260,10 @@ export const FIELD_HELP: Record = { "Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.", "browser.profiles.*.cdpUrl": "Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.", + "browser.profiles.*.userDataDir": + "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.", "browser.profiles.*.driver": - 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for host-local Chrome MCP attachment.', + 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for host-local Chrome DevTools MCP attachment.', "browser.profiles.*.attachOnly": "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.", "browser.profiles.*.color": @@ -1530,6 +1532,8 @@ export const FIELD_HELP: Record = { "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", "channels.telegram.timeoutSeconds": "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", + "channels.telegram.silentErrorReplies": + "When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.", "channels.telegram.threadBindings.enabled": "Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set.", "channels.telegram.threadBindings.idleHours": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 9541ad3b10a..a88cdc1ded5 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -123,6 +123,7 @@ export const FIELD_LABELS: Record = { "browser.profiles": "Browser Profiles", "browser.profiles.*.cdpPort": "Browser Profile CDP Port", "browser.profiles.*.cdpUrl": "Browser Profile CDP URL", + "browser.profiles.*.userDataDir": "Browser Profile User Data Dir", "browser.profiles.*.driver": "Browser Profile Driver", "browser.profiles.*.attachOnly": "Browser Profile Attach-only Mode", "browser.profiles.*.color": "Browser Profile Accent Color", @@ -737,6 +738,7 @@ export const FIELD_LABELS: Record = { "channels.telegram.retry.jitter": "Telegram Retry Jitter", "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", + "channels.telegram.silentErrorReplies": "Telegram Silent Error Replies", "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", "channels.telegram.execApprovals": "Telegram Exec Approvals", "channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled", diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 1a521836405..0942761d9f8 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -2,6 +2,7 @@ export * from "./sessions/group.js"; export * from "./sessions/artifacts.js"; export * from "./sessions/metadata.js"; export * from "./sessions/main-session.js"; +export * from "./sessions/main-session.runtime.js"; export * from "./sessions/paths.js"; export * from "./sessions/reset.js"; export * from "./sessions/session-key.js"; diff --git a/src/config/sessions/main-session.runtime.ts b/src/config/sessions/main-session.runtime.ts new file mode 100644 index 00000000000..1a06bb360ec --- /dev/null +++ b/src/config/sessions/main-session.runtime.ts @@ -0,0 +1,6 @@ +import { loadConfig } from "../io.js"; +import { resolveMainSessionKey } from "./main-session.js"; + +export function resolveMainSessionKeyFromConfig(): string { + return resolveMainSessionKey(loadConfig()); +} diff --git a/src/config/sessions/main-session.ts b/src/config/sessions/main-session.ts index b9e4ef16423..4e59993de2d 100644 --- a/src/config/sessions/main-session.ts +++ b/src/config/sessions/main-session.ts @@ -1,13 +1,16 @@ import { - buildAgentMainSessionKey, - DEFAULT_AGENT_ID, normalizeAgentId, normalizeMainKey, resolveAgentIdFromSessionKey, } from "../../routing/session-key.js"; -import { loadConfig } from "../config.js"; import type { SessionScope } from "./types.js"; +const FALLBACK_DEFAULT_AGENT_ID = "main"; + +function buildMainSessionKey(agentId: string, mainKey?: string): string { + return `agent:${normalizeAgentId(agentId)}:${normalizeMainKey(mainKey)}`; +} + export function resolveMainSessionKey(cfg?: { session?: { scope?: SessionScope; mainKey?: string }; agents?: { list?: Array<{ id?: string; default?: boolean }> }; @@ -17,14 +20,8 @@ export function resolveMainSessionKey(cfg?: { } const agents = cfg?.agents?.list ?? []; const defaultAgentId = - agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? DEFAULT_AGENT_ID; - const agentId = normalizeAgentId(defaultAgentId); - const mainKey = normalizeMainKey(cfg?.session?.mainKey); - return buildAgentMainSessionKey({ agentId, mainKey }); -} - -export function resolveMainSessionKeyFromConfig(): string { - return resolveMainSessionKey(loadConfig()); + agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? FALLBACK_DEFAULT_AGENT_ID; + return buildMainSessionKey(defaultAgentId, cfg?.session?.mainKey); } export { resolveAgentIdFromSessionKey }; @@ -33,8 +30,7 @@ export function resolveAgentMainSessionKey(params: { cfg?: { session?: { mainKey?: string } }; agentId: string; }): string { - const mainKey = normalizeMainKey(params.cfg?.session?.mainKey); - return buildAgentMainSessionKey({ agentId: params.agentId, mainKey }); + return buildMainSessionKey(params.agentId, params.cfg?.session?.mainKey); } export function resolveExplicitAgentSessionKey(params: { @@ -60,11 +56,8 @@ export function canonicalizeMainSessionAlias(params: { const agentId = normalizeAgentId(params.agentId); const mainKey = normalizeMainKey(params.cfg?.session?.mainKey); - const agentMainSessionKey = buildAgentMainSessionKey({ agentId, mainKey }); - const agentMainAliasKey = buildAgentMainSessionKey({ - agentId, - mainKey: "main", - }); + const agentMainSessionKey = buildMainSessionKey(agentId, mainKey); + const agentMainAliasKey = buildMainSessionKey(agentId, "main"); const isMainAlias = raw === "main" || raw === mainKey || raw === agentMainSessionKey || raw === agentMainAliasKey; diff --git a/src/config/sessions/store-read.ts b/src/config/sessions/store-read.ts new file mode 100644 index 00000000000..51c199f59e1 --- /dev/null +++ b/src/config/sessions/store-read.ts @@ -0,0 +1,21 @@ +import fs from "node:fs"; +import type { SessionEntry } from "./types.js"; + +function isSessionStoreRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function readSessionStoreReadOnly( + storePath: string, +): Record { + try { + const raw = fs.readFileSync(storePath, "utf-8"); + if (!raw.trim()) { + return {}; + } + const parsed = JSON.parse(raw); + return isSessionStoreRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index b50795fd9d0..558b0ed529f 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -3,6 +3,8 @@ export type BrowserProfileConfig = { cdpPort?: number; /** CDP URL for this profile (use for remote Chrome). */ cdpUrl?: string; + /** Explicit user data directory for existing-session Chrome MCP attachment. */ + userDataDir?: string; /** Profile driver (default: openclaw). */ driver?: "openclaw" | "clawd" | "existing-session"; /** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */ diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index fe1c5be3962..aa40cec7077 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -188,6 +188,8 @@ export type TelegramAccountConfig = { healthMonitor?: ChannelHealthMonitorConfig; /** Controls whether link previews are shown in outbound messages. Default: true. */ linkPreview?: boolean; + /** Send Telegram bot error replies silently (no notification sound). Default: false. */ + silentErrorReplies?: boolean; /** * Per-channel outbound response prefix override. * diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index da81ef61a4f..e65030d8f38 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -277,6 +277,7 @@ export const TelegramAccountSchemaBase = z heartbeat: ChannelHeartbeatVisibilitySchema, healthMonitor: ChannelHealthMonitorSchema, linkPreview: z.boolean().optional(), + silentErrorReplies: z.boolean().optional(), responsePrefix: z.string().optional(), ackReaction: z.string().optional(), }) diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index d1bce17b575..817183cab5d 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -359,6 +359,7 @@ export const OpenClawSchema = z .object({ cdpPort: z.number().int().min(1).max(65535).optional(), cdpUrl: z.string().optional(), + userDataDir: z.string().optional(), driver: z .union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")]) .optional(), @@ -371,7 +372,10 @@ export const OpenClawSchema = z { message: "Profile must set cdpPort or cdpUrl", }, - ), + ) + .refine((value) => value.driver === "existing-session" || !value.userDataDir, { + message: 'Profile userDataDir is only supported with driver="existing-session"', + }), ) .optional(), extraArgs: z.array(z.string()).optional(), diff --git a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts index 5678b75e4f7..58450d3a650 100644 --- a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts +++ b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts @@ -1,6 +1,7 @@ import "./isolated-agent.mocks.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import * as modelSelection from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { CliDeps } from "../cli/deps.js"; @@ -72,6 +73,7 @@ async function runTelegramAnnounceTurn(params: { describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { + vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue("off"); setupIsolatedAgentTurnMocks({ fast: true }); }); diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index b09a9db5ea1..5232d1349a6 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -1,6 +1,7 @@ import "./isolated-agent.mocks.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { loadModelCatalog } from "../agents/model-catalog.js"; +import * as modelSelection from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; @@ -125,6 +126,7 @@ async function expectInvalidModel(home: string, model: string) { describe("cron model formatting and precedence edge cases", () => { beforeEach(() => { + vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue("off"); vi.mocked(runEmbeddedPiAgent).mockClear(); vi.mocked(loadModelCatalog).mockResolvedValue([]); }); diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 5abbb453f35..9a5adcc2627 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -1,6 +1,7 @@ import "./isolated-agent.mocks.js"; import fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as modelSelection from "../agents/model-selection.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { CliDeps } from "../cli/deps.js"; import { @@ -261,6 +262,7 @@ async function assertExplicitTelegramTargetDelivery(params: { describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { + vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue("off"); setupIsolatedAgentTurnMocks(); }); diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index 2a4b786f99c..e7804835054 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { loadModelCatalog } from "../agents/model-catalog.js"; +import * as modelSelection from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { CliDeps } from "../cli/deps.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; @@ -163,6 +164,7 @@ async function runStoredOverrideAndExpectModel(params: { describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { + vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue("off"); vi.mocked(runEmbeddedPiAgent).mockClear(); vi.mocked(loadModelCatalog).mockResolvedValue([]); }); @@ -503,16 +505,9 @@ describe("runCronIsolatedAgentTurn", () => { }); }); - it("defaults thinking to low for reasoning-capable models", async () => { + it("passes through the resolved default thinking level", async () => { await withTempHome(async (home) => { - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ - { - id: "claude-opus-4-5", - name: "Opus 4.5", - provider: "anthropic", - reasoning: true, - }, - ]); + vi.mocked(modelSelection.resolveThinkingDefault).mockReturnValueOnce("low"); await runCronTurn(home, { jobPayload: DEFAULT_AGENT_TURN_PAYLOAD, diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 81e4c8b902b..0e9ac3c6069 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -363,7 +363,7 @@ export function resetRunCronIsolatedAgentTurnHarness(): void { resolveConfiguredModelRefMock.mockReturnValue({ provider: "openai", model: "gpt-4" }); resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }); resolveHooksGmailModelMock.mockReturnValue(null); - resolveThinkingDefaultMock.mockReturnValue(undefined); + resolveThinkingDefaultMock.mockReturnValue("off"); getModelRefStatusMock.mockReturnValue({ allowed: false }); isCliProviderMock.mockReturnValue(false); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 8a074338da7..9f3f28584e3 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -171,6 +171,27 @@ async function resolveCronDeliveryContext(params: { deliveryContract: IsolatedDeliveryContract; }) { const deliveryPlan = resolveCronDeliveryPlan(params.job); + if (!deliveryPlan.requested) { + const resolvedDelivery = { + ok: false as const, + channel: undefined, + to: undefined, + accountId: undefined, + threadId: undefined, + mode: "implicit" as const, + error: new Error("cron delivery not requested"), + }; + return { + deliveryPlan, + deliveryRequested: false, + resolvedDelivery, + toolPolicy: resolveCronToolPolicy({ + deliveryRequested: false, + resolvedDelivery, + deliveryContract: params.deliveryContract, + }), + }; + } const resolvedDelivery = await resolveDeliveryTarget(params.cfg, params.agentId, { channel: deliveryPlan.channel ?? "last", to: deliveryPlan.to, diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts index 6f56ef4f5c7..31b2cd07717 100644 --- a/src/docker-build-cache.test.ts +++ b/src/docker-build-cache.test.ts @@ -9,6 +9,10 @@ async function readRepoFile(path: string): Promise { return readFile(resolve(repoRoot, path), "utf8"); } +function indexOfPattern(source: string, pattern: RegExp): number { + return source.search(pattern); +} + describe("docker build cache layout", () => { it("keeps the root dependency layer independent from scripts changes", async () => { const dockerfile = await readRepoFile("Dockerfile"); @@ -29,8 +33,11 @@ describe("docker build cache layout", () => { "scripts/docker/cleanup-smoke/Dockerfile", ]) { const dockerfile = await readRepoFile(path); - expect(dockerfile, `${path} should use a shared pnpm store cache`).toContain( - "--mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked", + expect( + dockerfile, + `${path} should use a shared pnpm store cache under the active user's home`, + ).toMatch( + /--mount=type=cache,id=openclaw-pnpm-store,target=\/(?:root|home\/appuser)\/\.local\/share\/pnpm\/store,sharing=locked/, ); } }); @@ -87,23 +94,41 @@ describe("docker build cache layout", () => { const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); expect( - dockerfile.indexOf("COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./"), - ).toBeLessThan(installIndex); - expect(dockerfile.indexOf("COPY ui/package.json ./ui/package.json")).toBeLessThan(installIndex); - expect( - dockerfile.indexOf( - "COPY extensions/memory-core/package.json ./extensions/memory-core/package.json", + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.\/$/m, ), ).toBeLessThan(installIndex); expect( - dockerfile.indexOf( - "COPY tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./", + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+ui\/package\.json \.\/ui\/package\.json$/m, + ), + ).toBeLessThan(installIndex); + expect( + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+extensions\/memory-core\/package\.json \.\/extensions\/memory-core\/package\.json$/m, + ), + ).toBeLessThan(installIndex); + expect( + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsdown\.config\.ts vitest\.config\.ts vitest\.e2e\.config\.ts openclaw\.mjs \.\/$/m, ), ).toBeGreaterThan(installIndex); - expect(dockerfile.indexOf("COPY src ./src")).toBeGreaterThan(installIndex); - expect(dockerfile.indexOf("COPY test ./test")).toBeGreaterThan(installIndex); - expect(dockerfile.indexOf("COPY scripts ./scripts")).toBeGreaterThan(installIndex); - expect(dockerfile.indexOf("COPY ui ./ui")).toBeGreaterThan(installIndex); + expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+src \.\/src$/m)).toBeGreaterThan( + installIndex, + ); + expect( + indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+test \.\/test$/m), + ).toBeGreaterThan(installIndex); + expect( + indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+scripts \.\/scripts$/m), + ).toBeGreaterThan(installIndex); + expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+ui \.\/ui$/m)).toBeGreaterThan( + installIndex, + ); }); it("copies manifests before install in the qr-import image", async () => { @@ -111,17 +136,28 @@ describe("docker build cache layout", () => { const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); expect( - dockerfile.indexOf("COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./"), + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.\/$/m, + ), + ).toBeLessThan(installIndex); + expect( + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+ui\/package\.json \.\/ui\/package\.json$/m, + ), ).toBeLessThan(installIndex); - expect(dockerfile.indexOf("COPY ui/package.json ./ui/package.json")).toBeLessThan(installIndex); expect(dockerfile).toContain( "This image only exercises the root qrcode-terminal dependency path.", ); expect( - dockerfile.indexOf( - "COPY extensions/memory-core/package.json ./extensions/memory-core/package.json", + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+extensions\/memory-core\/package\.json \.\/extensions\/memory-core\/package\.json$/m, ), ).toBe(-1); - expect(dockerfile.indexOf("COPY . .")).toBeGreaterThan(installIndex); + expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+\.\s+\.$/m)).toBeGreaterThan( + installIndex, + ); }); }); diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index d3fdc89c86a..4108ed30e46 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -25,6 +25,7 @@ class MockWebSocket { readonly sent: string[] = []; closeCalls = 0; terminateCalls = 0; + autoCloseOnClose = true; constructor(_url: string, _options?: unknown) { wsInstances.push(this); @@ -55,7 +56,9 @@ class MockWebSocket { close(code?: number, reason?: string): void { this.closeCalls += 1; - this.emitClose(code ?? 1000, reason ?? ""); + if (this.autoCloseOnClose) { + this.emitClose(code ?? 1000, reason ?? ""); + } } terminate(): void { @@ -327,6 +330,39 @@ describe("GatewayClient close handling", () => { } }); + it("waits for a lingering socket to terminate in stopAndWait", async () => { + vi.useFakeTimers(); + try { + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + }); + + client.start(); + const ws = getLatestWs(); + ws.autoCloseOnClose = false; + + let settled = false; + const stopPromise = client.stopAndWait().then(() => { + settled = true; + }); + + expect(ws.closeCalls).toBe(1); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(249); + expect(ws.terminateCalls).toBe(0); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await stopPromise; + + expect(ws.terminateCalls).toBe(1); + expect(settled).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it("does not clear persisted device auth when explicit shared token is provided", () => { const onClose = vi.fn(); const identity: DeviceIdentity = { diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 0e30cef34e8..f4e49df1a10 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -120,6 +120,13 @@ export function describeGatewayCloseCode(code: number): string | undefined { } const FORCE_STOP_TERMINATE_GRACE_MS = 250; +const STOP_AND_WAIT_TIMEOUT_MS = 1_000; + +type PendingStop = { + ws: WebSocket; + promise: Promise; + resolve: () => void; +}; export class GatewayClient { private ws: WebSocket | null = null; @@ -139,6 +146,7 @@ export class GatewayClient { private tickIntervalMs = 30_000; private tickTimer: NodeJS.Timeout | null = null; private readonly requestTimeoutMs: number; + private pendingStop: PendingStop | null = null; constructor(opts: GatewayClientOptions) { this.opts = { @@ -217,9 +225,10 @@ export class GatewayClient { // oxlint-disable-next-line typescript/no-explicit-any }) as any; } - this.ws = new WebSocket(url, wsOptions); + const ws = new WebSocket(url, wsOptions); + this.ws = ws; - this.ws.on("open", () => { + ws.on("open", () => { if (url.startsWith("wss://") && this.opts.tlsFingerprint) { const tlsError = this.validateTlsFingerprint(); if (tlsError) { @@ -230,12 +239,15 @@ export class GatewayClient { } this.queueConnect(); }); - this.ws.on("message", (data) => this.handleMessage(rawDataToString(data))); - this.ws.on("close", (code, reason) => { + ws.on("message", (data) => this.handleMessage(rawDataToString(data))); + ws.on("close", (code, reason) => { const reasonText = rawDataToString(reason); const connectErrorDetailCode = this.pendingConnectErrorDetailCode; this.pendingConnectErrorDetailCode = null; - this.ws = null; + if (this.ws === ws) { + this.ws = null; + } + this.resolvePendingStop(ws); // Clear persisted device auth state only when device-token auth was active. // Shared token/password failures can return the same close reason but should // not erase a valid cached device token. @@ -265,7 +277,7 @@ export class GatewayClient { this.scheduleReconnect(); this.opts.onClose?.(code, reasonText); }); - this.ws.on("error", (err) => { + ws.on("error", (err) => { logDebug(`gateway client error: ${String(err)}`); if (!this.connectSent) { this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err))); @@ -274,6 +286,39 @@ export class GatewayClient { } stop() { + void this.beginStop(); + } + + async stopAndWait(opts?: { timeoutMs?: number }): Promise { + // Some callers need teardown ordering, not just "close requested". Wait for + // the socket to close or the terminate fallback to fire. + const stopPromise = this.beginStop(); + if (!stopPromise) { + return; + } + const timeoutMs = + typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(1, Math.floor(opts.timeoutMs)) + : STOP_AND_WAIT_TIMEOUT_MS; + let timeout: NodeJS.Timeout | null = null; + try { + await Promise.race([ + stopPromise, + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`gateway client stop timed out after ${timeoutMs}ms`)); + }, timeoutMs); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + } + + private beginStop(): Promise | null { this.closed = true; this.pendingDeviceTokenRetry = false; this.deviceTokenRetryBudgetUsed = false; @@ -282,18 +327,52 @@ export class GatewayClient { clearInterval(this.tickTimer); this.tickTimer = null; } + if (this.connectTimer) { + clearTimeout(this.connectTimer); + this.connectTimer = null; + } + if (this.pendingStop) { + this.flushPendingErrors(new Error("gateway client stopped")); + return this.pendingStop.promise; + } const ws = this.ws; this.ws = null; if (ws) { + const stopPromise = this.createPendingStop(ws); ws.close(); const forceTerminateTimer = setTimeout(() => { try { ws.terminate(); } catch {} + this.resolvePendingStop(ws); }, FORCE_STOP_TERMINATE_GRACE_MS); forceTerminateTimer.unref?.(); + this.flushPendingErrors(new Error("gateway client stopped")); + return stopPromise; } this.flushPendingErrors(new Error("gateway client stopped")); + return null; + } + + private createPendingStop(ws: WebSocket): Promise { + if (this.pendingStop?.ws === ws) { + return this.pendingStop.promise; + } + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + this.pendingStop = { ws, promise, resolve }; + return promise; + } + + private resolvePendingStop(ws: WebSocket): void { + if (this.pendingStop?.ws !== ws) { + return; + } + const { resolve } = this.pendingStop; + this.pendingStop = null; + resolve(); } private sendConnect() { diff --git a/src/gateway/gateway.test.ts b/src/gateway/gateway.test.ts index aea5a816fa7..5277673d408 100644 --- a/src/gateway/gateway.test.ts +++ b/src/gateway/gateway.test.ts @@ -7,12 +7,13 @@ import { startGatewayServer } from "./server.js"; import { extractPayloadText } from "./test-helpers.agent-results.js"; import { connectDeviceAuthReq, + disconnectGatewayClient, connectGatewayClient, getFreeGatewayPort, startGatewayWithClient, } from "./test-helpers.e2e.js"; import { installOpenAiResponsesMock } from "./test-helpers.openai-mock.js"; -import { buildOpenAiResponsesProviderConfig } from "./test-openai-responses-model.js"; +import { buildMockOpenAiResponsesProvider } from "./test-openai-responses-model.js"; let writeConfigFile: typeof import("../config/config.js").writeConfigFile; let resolveConfigPath: typeof import("../config/config.js").resolveConfigPath; @@ -67,13 +68,14 @@ describe("gateway e2e", () => { const configDir = path.join(tempHome, ".openclaw"); await fs.mkdir(configDir, { recursive: true }); const configPath = path.join(configDir, "openclaw.json"); + const mockProvider = buildMockOpenAiResponsesProvider(openaiBaseUrl); const cfg = { agents: { defaults: { workspace: workspaceDir } }, models: { mode: "replace", providers: { - openai: buildOpenAiResponsesProviderConfig(openaiBaseUrl), + [mockProvider.providerId]: mockProvider.config, }, }, gateway: { auth: { token } }, @@ -91,7 +93,7 @@ describe("gateway e2e", () => { await client.request("sessions.patch", { key: sessionKey, - model: "openai/gpt-5.2", + model: mockProvider.modelRef, }); const runId = nextGatewayId("run"); @@ -116,7 +118,7 @@ describe("gateway e2e", () => { expect(text).toContain(nonceA); expect(text).toContain(nonceB); } finally { - client.stop(); + await disconnectGatewayClient(client); await server.close({ reason: "mock openai test complete" }); await fs.rm(tempHome, { recursive: true, force: true }); restore(); @@ -216,7 +218,7 @@ describe("gateway e2e", () => { | undefined; expect((token?.auth as { token?: string } | undefined)?.token).toBe(wizardToken); } finally { - client.stop(); + await disconnectGatewayClient(client); await server.close({ reason: "wizard e2e complete" }); } diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 07425808cea..a5a7578ddbc 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -59,6 +59,13 @@ vi.mock("../infra/device-identity.js", () => ({ })); vi.mock("./session-utils.js", () => ({ loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)), + migrateAndPruneGatewaySessionStoreKey: vi.fn( + ({ key, store }: { key: string; store: Record }) => ({ + target: { canonicalKey: key, storeKeys: [key] }, + primaryKey: key, + entry: store[key], + }), + ), pruneLegacyStoreKeys: vi.fn(), resolveGatewaySessionStoreTarget: vi.fn(({ key }: { key: string }) => ({ canonicalKey: key, diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 2db21cccde1..489ce365d61 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -162,6 +162,65 @@ describe("loadGatewayPlugins", () => { expect(typeof subagent?.getSession).toBe("function"); }); + test("can prefer setup-runtime channel plugins during startup loads", async () => { + const { loadGatewayPlugins } = await importServerPluginsModule(); + loadOpenClawPlugins.mockReturnValue(createRegistry([])); + + const log = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + loadGatewayPlugins({ + cfg: {}, + workspaceDir: "/tmp", + log, + coreGatewayHandlers: {}, + baseMethods: [], + preferSetupRuntimeForChannelPlugins: true, + }); + + expect(loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + preferSetupRuntimeForChannelPlugins: true, + }), + ); + }); + + test("can suppress duplicate diagnostics when reloading full runtime plugins", async () => { + const { loadGatewayPlugins } = await importServerPluginsModule(); + const diagnostics: PluginDiagnostic[] = [ + { + level: "error", + pluginId: "telegram", + source: "/tmp/telegram/index.ts", + message: "failed to load plugin: boom", + }, + ]; + loadOpenClawPlugins.mockReturnValue(createRegistry(diagnostics)); + + const log = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + loadGatewayPlugins({ + cfg: {}, + workspaceDir: "/tmp", + log, + coreGatewayHandlers: {}, + baseMethods: [], + logDiagnostics: false, + }); + + expect(log.error).not.toHaveBeenCalled(); + expect(log.info).not.toHaveBeenCalled(); + }); + test("shares fallback context across module reloads for existing runtimes", async () => { const first = await importServerPluginsModule(); const runtime = createSubagentRuntime(first); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 7d8b2a8a051..4bcf8fa8d08 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -172,6 +172,8 @@ export function loadGatewayPlugins(params: { }; coreGatewayHandlers: Record; baseMethods: string[]; + preferSetupRuntimeForChannelPlugins?: boolean; + logDiagnostics?: boolean; }) { const pluginRegistry = loadOpenClawPlugins({ config: params.cfg, @@ -186,10 +188,11 @@ export function loadGatewayPlugins(params: { runtimeOptions: { subagent: createGatewaySubagentRuntime(), }, + preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, }); const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods])); - if (pluginRegistry.diagnostics.length > 0) { + if ((params.logDiagnostics ?? true) && pluginRegistry.diagnostics.length > 0) { for (const diag of pluginRegistry.diagnostics) { const details = [ diag.pluginId ? `plugin=${diag.pluginId}` : null, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 4c22e94bddf..cec8f2cb42a 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -10,8 +10,9 @@ import { formatCliCommand } from "../cli/command-format.js"; import { createDefaultDeps } from "../cli/deps.js"; import { isRestartEnabled } from "../config/commands.js"; import { - CONFIG_PATH, + type ConfigFileSnapshot, type OpenClawConfig, + applyConfigOverrides, isNixMode, loadConfig, migrateLegacyConfig, @@ -45,6 +46,7 @@ import { enqueueSystemEvent } from "../infra/system-events.js"; import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; +import { resolveConfiguredDeferredChannelPluginIds } from "../plugins/channel-plugin-ids.js"; import { getGlobalHookRunner, runGlobalGatewayStopSafely } from "../plugins/hook-runner-global.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { createPluginRuntime } from "../plugins/runtime/index.js"; @@ -216,6 +218,73 @@ function applyGatewayAuthOverridesForStartupPreflight( }; } +function assertValidGatewayStartupConfigSnapshot( + snapshot: ConfigFileSnapshot, + options: { includeDoctorHint?: boolean } = {}, +): void { + if (snapshot.valid) { + return; + } + const issues = + snapshot.issues.length > 0 + ? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n") + : "Unknown validation issue."; + const doctorHint = options.includeDoctorHint + ? `\nRun "${formatCliCommand("openclaw doctor")}" to repair, then retry.` + : ""; + throw new Error(`Invalid config at ${snapshot.path}.\n${issues}${doctorHint}`); +} + +async function prepareGatewayStartupConfig(params: { + configSnapshot: ConfigFileSnapshot; + // Keep startup auth/runtime behavior aligned with loadConfig(), which applies + // runtime overrides beyond the raw on-disk snapshot. + runtimeConfig: OpenClawConfig; + authOverride?: GatewayServerOptions["auth"]; + tailscaleOverride?: GatewayServerOptions["tailscale"]; + activateRuntimeSecrets: ( + config: OpenClawConfig, + options: { reason: "startup"; activate: boolean }, + ) => Promise<{ config: OpenClawConfig }>; +}): Promise>> { + assertValidGatewayStartupConfigSnapshot(params.configSnapshot); + + // Fail fast before startup auth persists anything if required refs are unresolved. + const startupPreflightConfig = applyGatewayAuthOverridesForStartupPreflight( + params.runtimeConfig, + { + auth: params.authOverride, + tailscale: params.tailscaleOverride, + }, + ); + await params.activateRuntimeSecrets(startupPreflightConfig, { + reason: "startup", + activate: false, + }); + + const authBootstrap = await ensureGatewayStartupAuth({ + cfg: params.runtimeConfig, + env: process.env, + authOverride: params.authOverride, + tailscaleOverride: params.tailscaleOverride, + persist: true, + }); + const runtimeStartupConfig = applyGatewayAuthOverridesForStartupPreflight(authBootstrap.cfg, { + auth: params.authOverride, + tailscale: params.tailscaleOverride, + }); + const activatedConfig = ( + await params.activateRuntimeSecrets(runtimeStartupConfig, { + reason: "startup", + activate: true, + }) + ).config; + return { + ...authBootstrap, + cfg: activatedConfig, + }; +} + export type GatewayServer = { close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise; }; @@ -314,20 +383,16 @@ export async function startGatewayServer( } configSnapshot = await readConfigFileSnapshot(); - if (configSnapshot.exists && !configSnapshot.valid) { - const issues = - configSnapshot.issues.length > 0 - ? formatConfigIssueLines(configSnapshot.issues, "", { normalizeRoot: true }).join("\n") - : "Unknown validation issue."; - throw new Error( - `Invalid config at ${configSnapshot.path}.\n${issues}\nRun "${formatCliCommand("openclaw doctor")}" to repair, then retry.`, - ); + if (configSnapshot.exists) { + assertValidGatewayStartupConfigSnapshot(configSnapshot, { includeDoctorHint: true }); } const autoEnable = applyPluginAutoEnable({ config: configSnapshot.config, env: process.env }); if (autoEnable.changes.length > 0) { try { await writeConfigFile(autoEnable.config); + configSnapshot = await readConfigFileSnapshot(); + assertValidGatewayStartupConfigSnapshot(configSnapshot); log.info( `gateway: auto-enabled plugins:\n${autoEnable.changes .map((entry) => `- ${entry}`) @@ -404,37 +469,14 @@ export async function startGatewayServer( } }); - // Fail fast before startup if required refs are unresolved. let cfgAtStart: OpenClawConfig; - { - const freshSnapshot = await readConfigFileSnapshot(); - if (!freshSnapshot.valid) { - const issues = - freshSnapshot.issues.length > 0 - ? formatConfigIssueLines(freshSnapshot.issues, "", { normalizeRoot: true }).join("\n") - : "Unknown validation issue."; - throw new Error(`Invalid config at ${freshSnapshot.path}.\n${issues}`); - } - const startupPreflightConfig = applyGatewayAuthOverridesForStartupPreflight( - freshSnapshot.config, - { - auth: opts.auth, - tailscale: opts.tailscale, - }, - ); - await activateRuntimeSecrets(startupPreflightConfig, { - reason: "startup", - activate: false, - }); - } - - cfgAtStart = loadConfig(); - const authBootstrap = await ensureGatewayStartupAuth({ - cfg: cfgAtStart, - env: process.env, + const startupRuntimeConfig = applyConfigOverrides(configSnapshot.config); + const authBootstrap = await prepareGatewayStartupConfig({ + configSnapshot, + runtimeConfig: startupRuntimeConfig, authOverride: opts.auth, tailscaleOverride: opts.tailscale, - persist: true, + activateRuntimeSecrets, }); cfgAtStart = authBootstrap.cfg; if (authBootstrap.generatedToken) { @@ -448,12 +490,6 @@ export async function startGatewayServer( ); } } - cfgAtStart = ( - await activateRuntimeSecrets(cfgAtStart, { - reason: "startup", - activate: true, - }) - ).config; const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart); if (diagnosticsEnabled) { startDiagnosticHeartbeat(); @@ -473,17 +509,27 @@ export async function startGatewayServer( initSubagentRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); + const deferredConfiguredChannelPluginIds = minimalTestGateway + ? [] + : resolveConfiguredDeferredChannelPluginIds({ + config: cfgAtStart, + workspaceDir: defaultWorkspaceDir, + env: process.env, + }); const baseMethods = listGatewayMethods(); const emptyPluginRegistry = createEmptyPluginRegistry(); - const { pluginRegistry, gatewayMethods: baseGatewayMethods } = minimalTestGateway - ? { pluginRegistry: emptyPluginRegistry, gatewayMethods: baseMethods } - : loadGatewayPlugins({ - cfg: cfgAtStart, - workspaceDir: defaultWorkspaceDir, - log, - coreGatewayHandlers, - baseMethods, - }); + let pluginRegistry = emptyPluginRegistry; + let baseGatewayMethods = baseMethods; + if (!minimalTestGateway) { + ({ pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayPlugins({ + cfg: cfgAtStart, + workspaceDir: defaultWorkspaceDir, + log, + coreGatewayHandlers, + baseMethods, + preferSetupRuntimeForChannelPlugins: deferredConfiguredChannelPluginIds.length > 0, + })); + } const channelLogs = Object.fromEntries( listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]), ) as Record>; @@ -940,6 +986,16 @@ export async function startGatewayServer( let browserControl: Awaited> = null; if (!minimalTestGateway) { + if (deferredConfiguredChannelPluginIds.length > 0) { + ({ pluginRegistry } = loadGatewayPlugins({ + cfg: cfgAtStart, + workspaceDir: defaultWorkspaceDir, + log, + coreGatewayHandlers, + baseMethods, + logDiagnostics: false, + })); + } ({ browserControl, pluginServices } = await startGatewaySidecars({ cfg: cfgAtStart, pluginRegistry, @@ -1040,7 +1096,7 @@ export async function startGatewayServer( warn: (msg) => logReload.warn(msg), error: (msg) => logReload.error(msg), }, - watchPath: CONFIG_PATH, + watchPath: configSnapshot.path, }); })(); diff --git a/src/gateway/test-helpers.e2e.ts b/src/gateway/test-helpers.e2e.ts index 34afd6614a8..eb0452c219e 100644 --- a/src/gateway/test-helpers.e2e.ts +++ b/src/gateway/test-helpers.e2e.ts @@ -104,6 +104,10 @@ export async function connectGatewayClient(params: { }); } +export async function disconnectGatewayClient(client: GatewayClient): Promise { + await client.stopAndWait(); +} + export async function connectDeviceAuthReq(params: { url: string; token?: string }) { const ws = new WebSocket(params.url); const connectNoncePromise = new Promise((resolve, reject) => { diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 59ad8a9cedc..4bfb7ef4e4d 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -367,6 +367,130 @@ vi.mock("../config/config.js", async () => { } }; + const composeTestConfig = (baseConfig: Record) => { + const fileAgents = + baseConfig.agents && + typeof baseConfig.agents === "object" && + !Array.isArray(baseConfig.agents) + ? (baseConfig.agents as Record) + : {}; + const fileDefaults = + fileAgents.defaults && + typeof fileAgents.defaults === "object" && + !Array.isArray(fileAgents.defaults) + ? (fileAgents.defaults as Record) + : {}; + const defaults = { + model: { primary: "anthropic/claude-opus-4-6" }, + workspace: path.join(os.tmpdir(), "openclaw-gateway-test"), + ...fileDefaults, + ...testState.agentConfig, + }; + const agents = testState.agentsConfig + ? { ...fileAgents, ...testState.agentsConfig, defaults } + : { ...fileAgents, defaults }; + + const fileBindings = Array.isArray(baseConfig.bindings) + ? (baseConfig.bindings as AgentBinding[]) + : undefined; + + const fileChannels = + baseConfig.channels && + typeof baseConfig.channels === "object" && + !Array.isArray(baseConfig.channels) + ? ({ ...(baseConfig.channels as Record) } as Record) + : {}; + const overrideChannels = + testState.channelsConfig && typeof testState.channelsConfig === "object" + ? { ...testState.channelsConfig } + : {}; + const mergedChannels = { ...fileChannels, ...overrideChannels }; + if (testState.allowFrom !== undefined) { + const existing = + mergedChannels.whatsapp && + typeof mergedChannels.whatsapp === "object" && + !Array.isArray(mergedChannels.whatsapp) + ? (mergedChannels.whatsapp as Record) + : {}; + mergedChannels.whatsapp = { + ...existing, + allowFrom: testState.allowFrom, + }; + } + const channels = Object.keys(mergedChannels).length > 0 ? mergedChannels : undefined; + + const fileSession = + baseConfig.session && + typeof baseConfig.session === "object" && + !Array.isArray(baseConfig.session) + ? (baseConfig.session as Record) + : {}; + const session: Record = { + ...fileSession, + mainKey: fileSession.mainKey ?? "main", + }; + if (typeof testState.sessionStorePath === "string") { + session.store = testState.sessionStorePath; + } + if (testState.sessionConfig) { + Object.assign(session, testState.sessionConfig); + } + + const fileGateway = + baseConfig.gateway && + typeof baseConfig.gateway === "object" && + !Array.isArray(baseConfig.gateway) + ? ({ ...(baseConfig.gateway as Record) } as Record) + : {}; + if (testState.gatewayBind) { + fileGateway.bind = testState.gatewayBind; + } + if (testState.gatewayAuth) { + fileGateway.auth = testState.gatewayAuth; + } + if (testState.gatewayControlUi) { + fileGateway.controlUi = testState.gatewayControlUi; + } + const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined; + + const fileCanvasHost = + baseConfig.canvasHost && + typeof baseConfig.canvasHost === "object" && + !Array.isArray(baseConfig.canvasHost) + ? ({ ...(baseConfig.canvasHost as Record) } as Record) + : {}; + if (typeof testState.canvasHostPort === "number") { + fileCanvasHost.port = testState.canvasHostPort; + } + const canvasHost = Object.keys(fileCanvasHost).length > 0 ? fileCanvasHost : undefined; + + const hooks = testState.hooksConfig ?? (baseConfig.hooks as HooksConfig | undefined); + + const fileCron = + baseConfig.cron && typeof baseConfig.cron === "object" && !Array.isArray(baseConfig.cron) + ? ({ ...(baseConfig.cron as Record) } as Record) + : {}; + if (typeof testState.cronEnabled === "boolean") { + fileCron.enabled = testState.cronEnabled; + } + if (typeof testState.cronStorePath === "string") { + fileCron.store = testState.cronStorePath; + } + const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined; + + return { + ...baseConfig, + agents, + bindings: testState.bindingsConfig ?? fileBindings, + channels, + session, + gateway, + canvasHost, + hooks, + cron, + } as OpenClawConfig; + }; + const writeConfigFile = vi.fn(async (cfg: Record) => { const configPath = resolveConfigPath(); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -389,6 +513,8 @@ vi.mock("../config/config.js", async () => { config: testState.migrationConfig ?? (raw as Record), changes: testState.migrationChanges, }), + applyConfigOverrides: (cfg: OpenClawConfig) => + composeTestConfig(cfg as Record), loadConfig: () => { const configPath = resolveConfigPath(); let fileConfig: Record = {}; @@ -400,129 +526,8 @@ vi.mock("../config/config.js", async () => { } catch { fileConfig = {}; } - - const fileAgents = - fileConfig.agents && - typeof fileConfig.agents === "object" && - !Array.isArray(fileConfig.agents) - ? (fileConfig.agents as Record) - : {}; - const fileDefaults = - fileAgents.defaults && - typeof fileAgents.defaults === "object" && - !Array.isArray(fileAgents.defaults) - ? (fileAgents.defaults as Record) - : {}; - const defaults = { - model: { primary: "anthropic/claude-opus-4-6" }, - workspace: path.join(os.tmpdir(), "openclaw-gateway-test"), - ...fileDefaults, - ...testState.agentConfig, - }; - const agents = testState.agentsConfig - ? { ...fileAgents, ...testState.agentsConfig, defaults } - : { ...fileAgents, defaults }; - - const fileBindings = Array.isArray(fileConfig.bindings) - ? (fileConfig.bindings as AgentBinding[]) - : undefined; - - const fileChannels = - fileConfig.channels && - typeof fileConfig.channels === "object" && - !Array.isArray(fileConfig.channels) - ? ({ ...(fileConfig.channels as Record) } as Record) - : {}; - const overrideChannels = - testState.channelsConfig && typeof testState.channelsConfig === "object" - ? { ...testState.channelsConfig } - : {}; - const mergedChannels = { ...fileChannels, ...overrideChannels }; - if (testState.allowFrom !== undefined) { - const existing = - mergedChannels.whatsapp && - typeof mergedChannels.whatsapp === "object" && - !Array.isArray(mergedChannels.whatsapp) - ? (mergedChannels.whatsapp as Record) - : {}; - mergedChannels.whatsapp = { - ...existing, - allowFrom: testState.allowFrom, - }; - } - const channels = Object.keys(mergedChannels).length > 0 ? mergedChannels : undefined; - - const fileSession = - fileConfig.session && - typeof fileConfig.session === "object" && - !Array.isArray(fileConfig.session) - ? (fileConfig.session as Record) - : {}; - const session: Record = { - ...fileSession, - mainKey: fileSession.mainKey ?? "main", - }; - if (typeof testState.sessionStorePath === "string") { - session.store = testState.sessionStorePath; - } - if (testState.sessionConfig) { - Object.assign(session, testState.sessionConfig); - } - - const fileGateway = - fileConfig.gateway && - typeof fileConfig.gateway === "object" && - !Array.isArray(fileConfig.gateway) - ? ({ ...(fileConfig.gateway as Record) } as Record) - : {}; - if (testState.gatewayBind) { - fileGateway.bind = testState.gatewayBind; - } - if (testState.gatewayAuth) { - fileGateway.auth = testState.gatewayAuth; - } - if (testState.gatewayControlUi) { - fileGateway.controlUi = testState.gatewayControlUi; - } - const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined; - - const fileCanvasHost = - fileConfig.canvasHost && - typeof fileConfig.canvasHost === "object" && - !Array.isArray(fileConfig.canvasHost) - ? ({ ...(fileConfig.canvasHost as Record) } as Record) - : {}; - if (typeof testState.canvasHostPort === "number") { - fileCanvasHost.port = testState.canvasHostPort; - } - const canvasHost = Object.keys(fileCanvasHost).length > 0 ? fileCanvasHost : undefined; - - const hooks = testState.hooksConfig ?? (fileConfig.hooks as HooksConfig | undefined); - - const fileCron = - fileConfig.cron && typeof fileConfig.cron === "object" && !Array.isArray(fileConfig.cron) - ? ({ ...(fileConfig.cron as Record) } as Record) - : {}; - if (typeof testState.cronEnabled === "boolean") { - fileCron.enabled = testState.cronEnabled; - } - if (typeof testState.cronStorePath === "string") { - fileCron.store = testState.cronStorePath; - } - const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined; - - const config = { - ...fileConfig, - agents, - bindings: testState.bindingsConfig ?? fileBindings, - channels, - session, - gateway, - canvasHost, - hooks, - cron, - }; - return applyPluginAutoEnable({ config, env: process.env }).config; + return applyPluginAutoEnable({ config: composeTestConfig(fileConfig), env: process.env }) + .config; }, parseConfigJson5: (raw: string) => { try { diff --git a/src/gateway/test-openai-responses-model.ts b/src/gateway/test-openai-responses-model.ts index 8d9cac2242d..77e32d1a6e8 100644 --- a/src/gateway/test-openai-responses-model.ts +++ b/src/gateway/test-openai-responses-model.ts @@ -1,3 +1,5 @@ +export const MOCK_OPENAI_RESPONSES_PROVIDER_ID = "mock-openai"; + export function buildOpenAiResponsesTestModel(id = "gpt-5.2") { return { id, @@ -19,3 +21,12 @@ export function buildOpenAiResponsesProviderConfig(baseUrl: string, modelId = "g models: [buildOpenAiResponsesTestModel(modelId)], } as const; } + +export function buildMockOpenAiResponsesProvider(baseUrl: string, modelId = "gpt-5.2") { + return { + providerId: MOCK_OPENAI_RESPONSES_PROVIDER_ID, + modelId, + modelRef: `${MOCK_OPENAI_RESPONSES_PROVIDER_ID}/${modelId}`, + config: buildOpenAiResponsesProviderConfig(baseUrl, modelId), + } as const; +} diff --git a/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts b/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts index 7875bd04a1d..d4d441fa687 100644 --- a/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts +++ b/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts @@ -5,12 +5,17 @@ import type { OpenClawConfig } from "../../../config/config.js"; const runBootOnce = vi.fn(); -vi.mock("../../../gateway/boot.js", () => ({ runBootOnce })); -vi.mock("../../../logging/subsystem.js", () => ({ - createSubsystemLogger: () => ({ +function createMockLogger() { + return { warn: vi.fn(), debug: vi.fn(), - }), + child: vi.fn(() => createMockLogger()), + }; +} + +vi.mock("../../../gateway/boot.js", () => ({ runBootOnce })); +vi.mock("../../../logging/subsystem.js", () => ({ + createSubsystemLogger: () => createMockLogger(), })); const { default: runBootChecklist } = await import("./handler.js"); diff --git a/src/hooks/bundled/boot-md/handler.test.ts b/src/hooks/bundled/boot-md/handler.test.ts index b212842fbae..de2abd6475f 100644 --- a/src/hooks/bundled/boot-md/handler.test.ts +++ b/src/hooks/bundled/boot-md/handler.test.ts @@ -10,16 +10,21 @@ const logDebug = vi.fn(); const MAIN_WORKSPACE_DIR = path.join(path.sep, "ws", "main"); const OPS_WORKSPACE_DIR = path.join(path.sep, "ws", "ops"); +function createMockLogger() { + return { + warn: logWarn, + debug: logDebug, + child: vi.fn(() => createMockLogger()), + }; +} + vi.mock("../../../gateway/boot.js", () => ({ runBootOnce })); vi.mock("../../../agents/agent-scope.js", () => ({ listAgentIds, resolveAgentWorkspaceDir, })); vi.mock("../../../logging/subsystem.js", () => ({ - createSubsystemLogger: () => ({ - warn: logWarn, - debug: logDebug, - }), + createSubsystemLogger: () => createMockLogger(), })); const { default: runBootChecklist } = await import("./handler.js"); diff --git a/src/infra/bonjour-ciao.test.ts b/src/infra/bonjour-ciao.test.ts index d3acc4a2c73..3d9ec83f0f4 100644 --- a/src/infra/bonjour-ciao.test.ts +++ b/src/infra/bonjour-ciao.test.ts @@ -9,7 +9,7 @@ vi.mock("../logger.js", () => ({ const { ignoreCiaoCancellationRejection } = await import("./bonjour-ciao.js"); describe("bonjour-ciao", () => { - it("ignores and logs ciao cancellation rejections", () => { + it("ignores and logs ciao announcement cancellation rejections", () => { expect( ignoreCiaoCancellationRejection(new Error("Ciao announcement cancelled by shutdown")), ).toBe(true); @@ -18,6 +18,15 @@ describe("bonjour-ciao", () => { ); }); + it("ignores and logs ciao probing cancellation rejections", () => { + logDebugMock.mockReset(); + + expect(ignoreCiaoCancellationRejection(new Error("CIAO PROBING CANCELLED"))).toBe(true); + expect(logDebugMock).toHaveBeenCalledWith( + expect.stringContaining("ignoring unhandled ciao rejection"), + ); + }); + it("ignores lower-case string cancellation reasons too", () => { logDebugMock.mockReset(); diff --git a/src/infra/bonjour-ciao.ts b/src/infra/bonjour-ciao.ts index 878997c6203..f39902c0aa7 100644 --- a/src/infra/bonjour-ciao.ts +++ b/src/infra/bonjour-ciao.ts @@ -1,9 +1,11 @@ import { logDebug } from "../logger.js"; import { formatBonjourError } from "./bonjour-errors.js"; +const CIAO_CANCELLATION_MESSAGE_RE = /^CIAO (?:ANNOUNCEMENT|PROBING) CANCELLED\b/u; + export function ignoreCiaoCancellationRejection(reason: unknown): boolean { const message = formatBonjourError(reason).toUpperCase(); - if (!message.includes("CIAO ANNOUNCEMENT CANCELLED")) { + if (!CIAO_CANCELLATION_MESSAGE_RE.test(message)) { return false; } logDebug(`bonjour: ignoring unhandled ciao rejection: ${formatBonjourError(reason)}`); diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index 10e5da610cc..7058cef6bb1 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -12,12 +12,23 @@ import { withEnvAsync } from "../test-utils/env.js"; import { clearMediaUnderstandingBinaryCacheForTests } from "./runner.js"; import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js"; -vi.mock("../agents/model-auth.js", () => ({ - resolveApiKeyForProvider: vi.fn(async () => ({ +const resolveApiKeyForProviderMock = vi.hoisted(() => + vi.fn(async () => ({ apiKey: "test-key", // pragma: allowlist secret source: "test", mode: "api-key", })), +); +const hasAvailableAuthForProviderMock = vi.hoisted(() => + vi.fn(async (...args: Parameters) => { + const resolved = await resolveApiKeyForProviderMock(...args); + return Boolean(resolved?.apiKey); + }), +); + +vi.mock("../agents/model-auth.js", () => ({ + resolveApiKeyForProvider: resolveApiKeyForProviderMock, + hasAvailableAuthForProvider: hasAvailableAuthForProviderMock, requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => { if (auth?.apiKey) { return auth.apiKey; @@ -247,6 +258,7 @@ describe("applyMediaUnderstanding", () => { source: "test", mode: "api-key", }); + hasAvailableAuthForProviderMock.mockClear(); mockedFetchRemoteMedia.mockClear(); mockedRunExec.mockReset(); mockedFetchRemoteMedia.mockResolvedValue({ diff --git a/src/media-understanding/runner.auto-audio.test.ts b/src/media-understanding/runner.auto-audio.test.ts index b2e282f3666..775e2ecb6be 100644 --- a/src/media-understanding/runner.auto-audio.test.ts +++ b/src/media-understanding/runner.auto-audio.test.ts @@ -1,5 +1,9 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { withEnvAsync } from "../test-utils/env.js"; import { buildProviderRegistry, runCapability } from "./runner.js"; import { withAudioFixture } from "./runner.test-utils.js"; @@ -109,68 +113,71 @@ describe("runCapability auto audio entries", () => { }); it("uses mistral when only mistral key is configured", async () => { - const priorEnv: Record = { - OPENAI_API_KEY: process.env.OPENAI_API_KEY, - GROQ_API_KEY: process.env.GROQ_API_KEY, - DEEPGRAM_API_KEY: process.env.DEEPGRAM_API_KEY, - GEMINI_API_KEY: process.env.GEMINI_API_KEY, - MISTRAL_API_KEY: process.env.MISTRAL_API_KEY, - }; - delete process.env.OPENAI_API_KEY; - delete process.env.GROQ_API_KEY; - delete process.env.DEEPGRAM_API_KEY; - delete process.env.GEMINI_API_KEY; - process.env.MISTRAL_API_KEY = "mistral-test-key"; // pragma: allowlist secret + const isolatedAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-audio-agent-")); let runResult: Awaited> | undefined; try { - await withAudioFixture("openclaw-auto-audio-mistral", async ({ ctx, media, cache }) => { - const providerRegistry = buildProviderRegistry({ - openai: { - id: "openai", - capabilities: ["audio"], - transcribeAudio: async () => ({ text: "openai", model: "gpt-4o-mini-transcribe" }), - }, - mistral: { - id: "mistral", - capabilities: ["audio"], - transcribeAudio: async (req) => ({ text: "mistral", model: req.model ?? "unknown" }), - }, - }); - const cfg = { - models: { - providers: { + await withEnvAsync( + { + OPENAI_API_KEY: undefined, + GROQ_API_KEY: undefined, + DEEPGRAM_API_KEY: undefined, + GEMINI_API_KEY: undefined, + GOOGLE_API_KEY: undefined, + MISTRAL_API_KEY: "mistral-test-key", // pragma: allowlist secret + OPENCLAW_AGENT_DIR: isolatedAgentDir, + PI_CODING_AGENT_DIR: isolatedAgentDir, + }, + async () => { + await withAudioFixture("openclaw-auto-audio-mistral", async ({ ctx, media, cache }) => { + const providerRegistry = buildProviderRegistry({ + openai: { + id: "openai", + capabilities: ["audio"], + transcribeAudio: async () => ({ + text: "openai", + model: "gpt-4o-mini-transcribe", + }), + }, mistral: { - apiKey: "mistral-test-key", // pragma: allowlist secret - models: [], + id: "mistral", + capabilities: ["audio"], + transcribeAudio: async (req) => ({ + text: "mistral", + model: req.model ?? "unknown", + }), }, - }, - }, - tools: { - media: { - audio: { - enabled: true, + }); + const cfg = { + models: { + providers: { + mistral: { + apiKey: "mistral-test-key", // pragma: allowlist secret + models: [], + }, + }, }, - }, - }, - } as unknown as OpenClawConfig; + tools: { + media: { + audio: { + enabled: true, + }, + }, + }, + } as unknown as OpenClawConfig; - runResult = await runCapability({ - capability: "audio", - cfg, - ctx, - attachments: cache, - media, - providerRegistry, - }); - }); + runResult = await runCapability({ + capability: "audio", + cfg, + ctx, + attachments: cache, + media, + providerRegistry, + }); + }); + }, + ); } finally { - for (const [key, value] of Object.entries(priorEnv)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } + await fs.rm(isolatedAgentDir, { recursive: true, force: true }); } if (!runResult) { throw new Error("Expected auto audio mistral result"); diff --git a/src/media-understanding/runner.ts b/src/media-understanding/runner.ts index c2ffe584448..a04cc6420fa 100644 --- a/src/media-understanding/runner.ts +++ b/src/media-understanding/runner.ts @@ -2,7 +2,7 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { resolveApiKeyForProvider } from "../agents/model-auth.js"; +import { hasAvailableAuthForProvider } from "../agents/model-auth.js"; import { findModelInCatalog, loadModelCatalog, @@ -362,12 +362,16 @@ async function resolveKeyEntry(params: { if (capability === "video" && !provider.describeVideo) { return null; } - try { - await resolveApiKeyForProvider({ provider: providerId, cfg, agentDir }); - return { type: "provider" as const, provider: providerId, model }; - } catch { + if ( + !(await hasAvailableAuthForProvider({ + provider: providerId, + cfg, + agentDir, + })) + ) { return null; } + return { type: "provider" as const, provider: providerId, model }; }; if (capability === "image") { @@ -553,13 +557,12 @@ async function resolveActiveModelEntry(params: { if (params.capability === "video" && !provider.describeVideo) { return null; } - try { - await resolveApiKeyForProvider({ - provider: providerId, - cfg: params.cfg, - agentDir: params.agentDir, - }); - } catch { + const hasAuth = await hasAvailableAuthForProvider({ + provider: providerId, + cfg: params.cfg, + agentDir: params.agentDir, + }); + if (!hasAuth) { return null; } return { diff --git a/src/media-understanding/runner.video.test.ts b/src/media-understanding/runner.video.test.ts index 90eab226cea..5c3992dfc55 100644 --- a/src/media-understanding/runner.video.test.ts +++ b/src/media-understanding/runner.video.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withEnvAsync } from "../test-utils/env.js"; @@ -74,62 +77,70 @@ describe("runCapability video provider wiring", () => { }); it("auto-selects moonshot for video when google is unavailable", async () => { - await withEnvAsync( - { - GEMINI_API_KEY: undefined, - MOONSHOT_API_KEY: undefined, - }, - async () => { - await withVideoFixture("openclaw-video-auto-moonshot", async ({ ctx, media, cache }) => { - const cfg = { - models: { - providers: { - moonshot: { - apiKey: "moonshot-key", // pragma: allowlist secret - models: [], + const isolatedAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-video-agent-")); + try { + await withEnvAsync( + { + GEMINI_API_KEY: undefined, + GOOGLE_API_KEY: undefined, + MOONSHOT_API_KEY: undefined, + OPENCLAW_AGENT_DIR: isolatedAgentDir, + PI_CODING_AGENT_DIR: isolatedAgentDir, + }, + async () => { + await withVideoFixture("openclaw-video-auto-moonshot", async ({ ctx, media, cache }) => { + const cfg = { + models: { + providers: { + moonshot: { + apiKey: "moonshot-key", // pragma: allowlist secret + models: [], + }, }, }, - }, - tools: { - media: { - video: { - enabled: true, + tools: { + media: { + video: { + enabled: true, + }, }, }, - }, - } as unknown as OpenClawConfig; + } as unknown as OpenClawConfig; - const result = await runCapability({ - capability: "video", - cfg, - ctx, - attachments: cache, - media, - providerRegistry: new Map([ - [ - "google", - { - id: "google", - capabilities: ["video"], - describeVideo: async () => ({ text: "google" }), - }, - ], - [ - "moonshot", - { - id: "moonshot", - capabilities: ["video"], - describeVideo: async () => ({ text: "moonshot", model: "kimi-k2.5" }), - }, - ], - ]), + const result = await runCapability({ + capability: "video", + cfg, + ctx, + attachments: cache, + media, + providerRegistry: new Map([ + [ + "google", + { + id: "google", + capabilities: ["video"], + describeVideo: async () => ({ text: "google" }), + }, + ], + [ + "moonshot", + { + id: "moonshot", + capabilities: ["video"], + describeVideo: async () => ({ text: "moonshot", model: "kimi-k2.5" }), + }, + ], + ]), + }); + + expect(result.decision.outcome).toBe("success"); + expect(result.outputs[0]?.provider).toBe("moonshot"); + expect(result.outputs[0]?.text).toBe("moonshot"); }); - - expect(result.decision.outcome).toBe("success"); - expect(result.outputs[0]?.provider).toBe("moonshot"); - expect(result.outputs[0]?.text).toBe("moonshot"); - }); - }, - ); + }, + ); + } finally { + await fs.rm(isolatedAgentDir, { recursive: true, force: true }); + } }); }); diff --git a/src/plugin-sdk/channel-plugin-common.ts b/src/plugin-sdk/channel-plugin-common.ts index 59c347c8f0c..3c5153733c0 100644 --- a/src/plugin-sdk/channel-plugin-common.ts +++ b/src/plugin-sdk/channel-plugin-common.ts @@ -1,4 +1,8 @@ +// Canonical shared prelude for channel-oriented plugin SDK surfaces. +// Keep `core` and channel-specific SDK entrypoints derived from this module +// so bundled channel entrypoints do not drift across overlapping exports. export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export type { ChannelMessageActionContext } from "../channels/plugins/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 01807d79132..0c521f84122 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -1,6 +1,5 @@ export type { AnyAgentTool, - OpenClawPluginApi, OpenClawPluginConfigSchema, ProviderDiscoveryContext, ProviderCatalogContext, @@ -31,29 +30,6 @@ export type { ProviderAuthMethod, ProviderAuthResult, } from "../plugins/types.js"; -export type { - CreateSandboxBackendParams, - RemoteShellSandboxHandle, - RunSshSandboxCommandParams, - SandboxBackendCommandParams, - SandboxBackendCommandResult, - SandboxBackendExecSpec, - SandboxBackendFactory, - SandboxFsBridge, - SandboxFsStat, - SandboxBackendHandle, - SandboxBackendId, - SandboxBackendManager, - SandboxBackendRegistration, - SandboxBackendRuntimeInfo, - SandboxContext, - SandboxResolvedPath, - SandboxSshConfig, - SshSandboxSession, - SshSandboxSettings, -} from "../agents/sandbox.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js"; export type { @@ -61,60 +37,15 @@ export type { UsageProviderId, UsageWindow, } from "../infra/provider-usage.types.js"; +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export { - buildExecRemoteCommand, - buildRemoteCommand, - buildSshSandboxArgv, - createRemoteShellSandboxFsBridge, - createSshSandboxSessionFromConfigText, - createSshSandboxSessionFromSettings, - disposeSshSandboxSession, - getSandboxBackendFactory, - getSandboxBackendManager, - registerSandboxBackend, - runSshSandboxCommand, - shellEscape, - uploadDirectoryToSshTarget, - requireSandboxBackendFactory, -} from "../agents/sandbox.js"; +export { emptyPluginConfigSchema } from "./channel-plugin-common.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; -export { - applyProviderDefaultModel, - configureOpenAICompatibleSelfHostedProviderNonInteractive, - discoverOpenAICompatibleSelfHostedProvider, - promptAndConfigureOpenAICompatibleSelfHostedProvider, - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth, - SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, - SELF_HOSTED_DEFAULT_COST, - SELF_HOSTED_DEFAULT_MAX_TOKENS, -} from "../commands/self-hosted-provider-setup.js"; -export { - OLLAMA_DEFAULT_BASE_URL, - OLLAMA_DEFAULT_MODEL, - configureOllamaNonInteractive, - ensureOllamaModelPulled, - promptAndConfigureOllama, -} from "../commands/ollama-setup.js"; -export { - VLLM_DEFAULT_BASE_URL, - VLLM_DEFAULT_CONTEXT_WINDOW, - VLLM_DEFAULT_COST, - VLLM_DEFAULT_MAX_TOKENS, - promptAndConfigureVllm, -} from "../commands/vllm-setup.js"; -export { - buildOllamaProvider, - buildSglangProvider, - buildVllmProvider, -} from "../agents/models-config.providers.discovery.js"; - -export { - approveDevicePairing, - listDevicePairing, - rejectDevicePairing, -} from "../infra/device-pairing.js"; export { DEFAULT_SECRET_FILE_MAX_BYTES, loadSecretFileSync, @@ -123,13 +54,6 @@ export { } from "../infra/secret-file.js"; export type { SecretFileReadOptions, SecretFileReadResult } from "../infra/secret-file.js"; -export { - runPluginCommandWithTimeout, - type PluginCommandRunOptions, - type PluginCommandRunResult, -} from "./run-command.js"; -export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; - export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; export type { GatewayBindUrlResult } from "../shared/gateway-bind-url.js"; diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 82ffb8dde5c..d15f5091b9d 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,7 +1,25 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; -export * from "./channel-plugin-common.js"; +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, + applyAccountNameToChannelSection, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; export { projectCredentialSnapshotFields, diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index f896799b323..a974910e680 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -1,5 +1,23 @@ export type { IMessageAccountConfig } from "../config/types.js"; -export * from "./channel-plugin-common.js"; +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, + applyAccountNameToChannelSection, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; export { formatTrimmedAllowFromEntries, resolveIMessageConfigAllowFrom, diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index c21ee9661fb..b6617199472 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -6,15 +6,14 @@ export type { export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { ReplyPayload } from "../auto-reply/types.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; +export type { OpenClawPluginApi, PluginRuntime } from "./channel-plugin-common.js"; -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; - -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + emptyPluginConfigSchema, +} from "./channel-plugin-common.js"; export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; export { diff --git a/src/plugin-sdk/ollama-setup.ts b/src/plugin-sdk/ollama-setup.ts new file mode 100644 index 00000000000..5b6fd732774 --- /dev/null +++ b/src/plugin-sdk/ollama-setup.ts @@ -0,0 +1,17 @@ +export type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderAuthMethodNonInteractiveContext, + ProviderAuthResult, + ProviderDiscoveryContext, +} from "../plugins/types.js"; + +export { + OLLAMA_DEFAULT_BASE_URL, + OLLAMA_DEFAULT_MODEL, + configureOllamaNonInteractive, + ensureOllamaModelPulled, + promptAndConfigureOllama, +} from "../commands/ollama-setup.js"; + +export { buildOllamaProvider } from "../agents/models-config.providers.discovery.js"; diff --git a/src/plugin-sdk/provider-setup.ts b/src/plugin-sdk/provider-setup.ts new file mode 100644 index 00000000000..6569c36a324 --- /dev/null +++ b/src/plugin-sdk/provider-setup.ts @@ -0,0 +1,37 @@ +export type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderAuthMethodNonInteractiveContext, + ProviderAuthResult, + ProviderDiscoveryContext, +} from "../plugins/types.js"; + +export { + applyProviderDefaultModel, + configureOpenAICompatibleSelfHostedProviderNonInteractive, + discoverOpenAICompatibleSelfHostedProvider, + promptAndConfigureOpenAICompatibleSelfHostedProvider, + promptAndConfigureOpenAICompatibleSelfHostedProviderAuth, + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "../commands/self-hosted-provider-setup.js"; +export { + OLLAMA_DEFAULT_BASE_URL, + OLLAMA_DEFAULT_MODEL, + configureOllamaNonInteractive, + ensureOllamaModelPulled, + promptAndConfigureOllama, +} from "../commands/ollama-setup.js"; +export { + VLLM_DEFAULT_BASE_URL, + VLLM_DEFAULT_CONTEXT_WINDOW, + VLLM_DEFAULT_COST, + VLLM_DEFAULT_MAX_TOKENS, + promptAndConfigureVllm, +} from "../commands/vllm-setup.js"; +export { + buildOllamaProvider, + buildSglangProvider, + buildVllmProvider, +} from "../agents/models-config.providers.discovery.js"; diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 12d98caf8a8..9f3ab45379f 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -169,9 +169,8 @@ rootExports = new Proxy(target, { }, ownKeys() { const keys = new Set(Reflect.ownKeys(target)); - const monolithic = getMonolithicSdk(); - if (monolithic) { - for (const key of Reflect.ownKeys(monolithic)) { + if (monolithicSdk && typeof monolithicSdk === "object") { + for (const key of Reflect.ownKeys(monolithicSdk)) { if (!keys.has(key)) { keys.add(key); } diff --git a/src/plugin-sdk/sandbox.ts b/src/plugin-sdk/sandbox.ts new file mode 100644 index 00000000000..ce349fb9de5 --- /dev/null +++ b/src/plugin-sdk/sandbox.ts @@ -0,0 +1,46 @@ +export type { + CreateSandboxBackendParams, + RemoteShellSandboxHandle, + RunSshSandboxCommandParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendExecSpec, + SandboxBackendFactory, + SandboxFsBridge, + SandboxFsStat, + SandboxBackendHandle, + SandboxBackendId, + SandboxBackendManager, + SandboxBackendRegistration, + SandboxBackendRuntimeInfo, + SandboxContext, + SandboxResolvedPath, + SandboxSshConfig, + SshSandboxSession, + SshSandboxSettings, +} from "../agents/sandbox.js"; +export type { OpenClawConfig } from "../config/config.js"; + +export { + buildExecRemoteCommand, + buildRemoteCommand, + buildSshSandboxArgv, + createRemoteShellSandboxFsBridge, + createSshSandboxSessionFromConfigText, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, + requireSandboxBackendFactory, + runSshSandboxCommand, + shellEscape, + uploadDirectoryToSshTarget, +} from "../agents/sandbox.js"; + +export { + runPluginCommandWithTimeout, + type PluginCommandRunOptions, + type PluginCommandRunResult, +} from "./run-command.js"; +export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; diff --git a/src/plugin-sdk/self-hosted-provider-setup.ts b/src/plugin-sdk/self-hosted-provider-setup.ts new file mode 100644 index 00000000000..950bbbb953e --- /dev/null +++ b/src/plugin-sdk/self-hosted-provider-setup.ts @@ -0,0 +1,23 @@ +export type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderAuthMethodNonInteractiveContext, + ProviderAuthResult, + ProviderDiscoveryContext, +} from "../plugins/types.js"; + +export { + applyProviderDefaultModel, + configureOpenAICompatibleSelfHostedProviderNonInteractive, + discoverOpenAICompatibleSelfHostedProvider, + promptAndConfigureOpenAICompatibleSelfHostedProvider, + promptAndConfigureOpenAICompatibleSelfHostedProviderAuth, + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "../commands/self-hosted-provider-setup.js"; + +export { + buildSglangProvider, + buildVllmProvider, +} from "../agents/models-config.providers.discovery.js"; diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index 86f83b06318..8fd6fd2afd0 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,6 +1,24 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { SignalAccountConfig } from "../config/types.js"; -export * from "./channel-plugin-common.js"; +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, + applyAccountNameToChannelSection, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; export { looksLikeSignalTargetId, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 93ad140bfad..f7533b95687 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -1,6 +1,24 @@ export type { OpenClawConfig } from "../config/config.js"; export type { SlackAccountConfig } from "../config/types.slack.js"; -export * from "./channel-plugin-common.js"; +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, + applyAccountNameToChannelSection, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; export { projectCredentialSnapshotFields, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 5e3f62849d7..eff2820af79 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,16 +1,33 @@ import * as extensionApi from "openclaw/extension-api"; import * as compatSdk from "openclaw/plugin-sdk/compat"; import * as coreSdk from "openclaw/plugin-sdk/core"; +import type { + ChannelMessageActionContext as CoreChannelMessageActionContext, + OpenClawPluginApi as CoreOpenClawPluginApi, + PluginRuntime as CorePluginRuntime, +} from "openclaw/plugin-sdk/core"; import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as lineSdk from "openclaw/plugin-sdk/line"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; import * as nostrSdk from "openclaw/plugin-sdk/nostr"; +import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; +import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup"; +import * as sandboxSdk from "openclaw/plugin-sdk/sandbox"; +import * as selfHostedProviderSetupSdk from "openclaw/plugin-sdk/self-hosted-provider-setup"; import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; +import type { PluginRuntime } from "../plugins/runtime/types.js"; +import type { OpenClawPluginApi } from "../plugins/types.js"; +import type { + ChannelMessageActionContext as SharedChannelMessageActionContext, + OpenClawPluginApi as SharedOpenClawPluginApi, + PluginRuntime as SharedPluginRuntime, +} from "./channel-plugin-common.js"; import { pluginSdkSubpaths } from "./entrypoints.js"; const importPluginSdkSubpath = (specifier: string) => import(/* @vite-ignore */ specifier); @@ -33,6 +50,53 @@ describe("plugin-sdk subpath exports", () => { expect(typeof coreSdk.resolveThreadSessionKeys).toBe("function"); expect(typeof coreSdk.runPassiveAccountLifecycle).toBe("function"); expect(typeof coreSdk.createLoggerBackedRuntime).toBe("function"); + expect("registerSandboxBackend" in asExports(coreSdk)).toBe(false); + expect("promptAndConfigureOpenAICompatibleSelfHostedProviderAuth" in asExports(coreSdk)).toBe( + false, + ); + }); + + it("exports provider setup helpers from the dedicated subpath", () => { + expect(typeof providerSetupSdk.buildVllmProvider).toBe("function"); + expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function"); + expect(typeof providerSetupSdk.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth).toBe( + "function", + ); + }); + + it("exports narrow self-hosted provider setup helpers", () => { + expect(typeof selfHostedProviderSetupSdk.buildVllmProvider).toBe("function"); + expect(typeof selfHostedProviderSetupSdk.buildSglangProvider).toBe("function"); + expect(typeof selfHostedProviderSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe( + "function", + ); + expect( + typeof selfHostedProviderSetupSdk.configureOpenAICompatibleSelfHostedProviderNonInteractive, + ).toBe("function"); + }); + + it("exports narrow Ollama setup helpers", () => { + expect(typeof ollamaSetupSdk.buildOllamaProvider).toBe("function"); + expect(typeof ollamaSetupSdk.configureOllamaNonInteractive).toBe("function"); + expect(typeof ollamaSetupSdk.ensureOllamaModelPulled).toBe("function"); + }); + + it("exports sandbox helpers from the dedicated subpath", () => { + expect(typeof sandboxSdk.registerSandboxBackend).toBe("function"); + expect(typeof sandboxSdk.runPluginCommandWithTimeout).toBe("function"); + expect(typeof sandboxSdk.createRemoteShellSandboxFsBridge).toBe("function"); + }); + + it("exports shared core types used by bundled channels", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); + + it("keeps core shared types aligned with the channel prelude", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); }); it("exports Discord helpers", () => { diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 397a48fa019..2eed87097f0 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -3,28 +3,29 @@ export type { ChannelGatewayContext, ChannelMessageActionAdapter, } from "../channels/plugins/types.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; export type { TelegramAccountConfig, TelegramActionConfig } from "../config/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; export { + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { + buildChannelConfigSchema, deleteAccountFromConfigSection, - clearAccountEntryFields, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; -export { getChatChannelMeta } from "../channels/registry.js"; +} from "./channel-plugin-common.js"; + +export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; export { projectCredentialSnapshotFields, diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index 7e4debbef43..df814fa04eb 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -1,20 +1,25 @@ export type { ChannelMessageActionName } from "../channels/plugins/types.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; export { + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, applyAccountNameToChannelSection, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { getChatChannelMeta } from "../channels/registry.js"; + normalizeAccountId, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; export { formatWhatsAppConfigAllowFromEntries, resolveWhatsAppConfigAllowFrom, diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts new file mode 100644 index 00000000000..8bc1fc9cf76 --- /dev/null +++ b/src/plugins/bundled-dir.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveBundledPluginsDir } from "./bundled-dir.js"; + +const tempDirs: string[] = []; +const originalCwd = process.cwd(); +const originalBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; +const originalWatchMode = process.env.OPENCLAW_WATCH_MODE; + +function makeRepoRoot(prefix: string): string { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(repoRoot); + return repoRoot; +} + +afterEach(() => { + process.chdir(originalCwd); + if (originalBundledDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledDir; + } + if (originalWatchMode === undefined) { + delete process.env.OPENCLAW_WATCH_MODE; + } else { + process.env.OPENCLAW_WATCH_MODE = originalWatchMode; + } + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("resolveBundledPluginsDir", () => { + it("prefers source extensions from the package root in watch mode", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-dir-watch-"); + fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, "package.json"), + `${JSON.stringify({ name: "openclaw" }, null, 2)}\n`, + "utf8", + ); + + process.chdir(repoRoot); + process.env.OPENCLAW_WATCH_MODE = "1"; + + expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe( + fs.realpathSync(path.join(repoRoot, "extensions")), + ); + }); +}); diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 89d43444640..7fa25092f42 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { resolveUserPath } from "../utils.js"; export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined { @@ -9,6 +10,22 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): return resolveUserPath(override, env); } + if (env.OPENCLAW_WATCH_MODE === "1") { + try { + const packageRoot = resolveOpenClawPackageRootSync({ cwd: process.cwd() }); + if (packageRoot) { + // In watch mode, prefer source plugin roots so plugin-local runtime deps + // resolve from extensions//node_modules instead of stripped dist copies. + const sourceExtensionsDir = path.join(packageRoot, "extensions"); + if (fs.existsSync(sourceExtensionsDir)) { + return sourceExtensionsDir; + } + } + } catch { + // ignore + } + } + // bun --compile: ship a sibling `extensions/` next to the executable. try { const execDir = path.dirname(process.execPath); diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts new file mode 100644 index 00000000000..b5a22f15b63 --- /dev/null +++ b/src/plugins/channel-plugin-ids.ts @@ -0,0 +1,55 @@ +import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; + +export function resolveChannelPluginIds(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + return loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .plugins.filter((plugin) => plugin.channels.length > 0) + .map((plugin) => plugin.id); +} + +export function resolveConfiguredChannelPluginIds(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + const configuredChannelIds = new Set( + listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), + ); + if (configuredChannelIds.size === 0) { + return []; + } + return resolveChannelPluginIds(params).filter((pluginId) => configuredChannelIds.has(pluginId)); +} + +export function resolveConfiguredDeferredChannelPluginIds(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + const configuredChannelIds = new Set( + listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), + ); + if (configuredChannelIds.size === 0) { + return []; + } + return loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .plugins.filter( + (plugin) => + plugin.channels.some((channelId) => configuredChannelIds.has(channelId)) && + plugin.startupDeferConfiguredChannelFullLoadUntilAfterListen === true, + ) + .map((plugin) => plugin.id); +} diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 6f371305a81..d95a98b18d9 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -136,6 +136,22 @@ describe("registerPluginCommand", () => { }); }); + it("resolves Telegram topic command bindings without a Telegram registry entry", () => { + expect( + __testing.resolveBindingConversationFromCommand({ + channel: "telegram", + from: "telegram:group:-100123", + to: "telegram:group:-100123:topic:77", + accountId: "default", + }), + ).toEqual({ + channel: "telegram", + accountId: "default", + conversationId: "-100123", + threadId: 77, + }); + }); + it("does not resolve binding conversations for unsupported command channels", () => { expect( __testing.resolveBindingConversationFromCommand({ diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index 9ca5f1184e6..072e657616e 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -22,8 +22,8 @@ vi.mock("../../../extensions/github-copilot/token.js", async () => { }; }); -vi.mock("openclaw/plugin-sdk/core", async () => { - const actual = await vi.importActual("openclaw/plugin-sdk/core"); +vi.mock("openclaw/plugin-sdk/provider-setup", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/provider-setup"); return { ...actual, buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args), @@ -32,6 +32,23 @@ vi.mock("openclaw/plugin-sdk/core", async () => { }; }); +vi.mock("openclaw/plugin-sdk/self-hosted-provider-setup", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/self-hosted-provider-setup"); + return { + ...actual, + buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args), + buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args), + }; +}); + +vi.mock("openclaw/plugin-sdk/ollama-setup", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/ollama-setup"); + return { + ...actual, + buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args), + }; +}); + const qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default; const githubCopilotPlugin = (await import("../../../extensions/github-copilot/index.js")).default; const ollamaPlugin = (await import("../../../extensions/ollama/index.js")).default; diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index a1f82f56fda..a42c24712ec 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -75,15 +75,12 @@ describe("plugin loader contract", () => { webSearchProviderContractRegistry.map((entry) => entry.pluginId), ); - resolvePluginWebSearchProviders({}); + const providers = resolvePluginWebSearchProviders({}); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - onlyPluginIds: webSearchPluginIds, - activate: false, - cache: false, - }), + expect(uniqueSortedPluginIds(providers.map((provider) => provider.pluginId))).toEqual( + webSearchPluginIds, ); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); it("keeps bundled web search allowlist compatibility wired to the web search registry", () => { @@ -91,7 +88,7 @@ describe("plugin loader contract", () => { webSearchProviderContractRegistry.map((entry) => entry.pluginId), ); - resolvePluginWebSearchProviders({ + const providers = resolvePluginWebSearchProviders({ bundledAllowlistCompat: true, config: { plugins: { @@ -100,15 +97,9 @@ describe("plugin loader contract", () => { }, }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: expect.arrayContaining(webSearchPluginIds), - }), - }), - onlyPluginIds: webSearchPluginIds, - }), + expect(uniqueSortedPluginIds(providers.map((provider) => provider.pluginId))).toEqual( + webSearchPluginIds, ); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 82867213fdd..a91b6c939ab 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -348,7 +348,9 @@ afterEach(() => { describe("bundle plugins", () => { it("reports Codex bundles as loaded bundle plugins without importing runtime code", () => { + useNoBundledPlugins(); const workspaceDir = makeTempDir(); + const stateDir = makeTempDir(); const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "sample-bundle"); mkdirSafe(path.join(bundleRoot, ".codex-plugin")); mkdirSafe(path.join(bundleRoot, "skills")); @@ -366,19 +368,22 @@ describe("bundle plugins", () => { "---\ndescription: fixture\n---\n", ); - const registry = loadOpenClawPlugins({ - workspaceDir, - config: { - plugins: { - entries: { - "sample-bundle": { - enabled: true, + const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => + loadOpenClawPlugins({ + workspaceDir, + onlyPluginIds: ["sample-bundle"], + config: { + plugins: { + entries: { + "sample-bundle": { + enabled: true, + }, }, }, }, - }, - cache: false, - }); + cache: false, + }), + ); const plugin = registry.plugins.find((entry) => entry.id === "sample-bundle"); expect(plugin?.status).toBe("loaded"); @@ -388,7 +393,9 @@ describe("bundle plugins", () => { }); it("treats Claude command roots and settings as supported bundle surfaces", () => { + useNoBundledPlugins(); const workspaceDir = makeTempDir(); + const stateDir = makeTempDir(); const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-skills"); mkdirSafe(path.join(bundleRoot, "commands")); fs.writeFileSync( @@ -397,19 +404,22 @@ describe("bundle plugins", () => { ); fs.writeFileSync(path.join(bundleRoot, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); - const registry = loadOpenClawPlugins({ - workspaceDir, - config: { - plugins: { - entries: { - "claude-skills": { - enabled: true, + const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => + loadOpenClawPlugins({ + workspaceDir, + onlyPluginIds: ["claude-skills"], + config: { + plugins: { + entries: { + "claude-skills": { + enabled: true, + }, }, }, }, - }, - cache: false, - }); + cache: false, + }), + ); const plugin = registry.plugins.find((entry) => entry.id === "claude-skills"); expect(plugin?.status).toBe("loaded"); @@ -427,7 +437,9 @@ describe("bundle plugins", () => { }); it("treats Cursor command roots as supported bundle skill surfaces", () => { + useNoBundledPlugins(); const workspaceDir = makeTempDir(); + const stateDir = makeTempDir(); const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "cursor-skills"); mkdirSafe(path.join(bundleRoot, ".cursor-plugin")); mkdirSafe(path.join(bundleRoot, ".cursor", "commands")); @@ -443,19 +455,22 @@ describe("bundle plugins", () => { "---\ndescription: fixture\n---\n", ); - const registry = loadOpenClawPlugins({ - workspaceDir, - config: { - plugins: { - entries: { - "cursor-skills": { - enabled: true, + const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => + loadOpenClawPlugins({ + workspaceDir, + onlyPluginIds: ["cursor-skills"], + config: { + plugins: { + entries: { + "cursor-skills": { + enabled: true, + }, }, }, }, - }, - cache: false, - }); + cache: false, + }), + ); const plugin = registry.plugins.find((entry) => entry.id === "cursor-skills"); expect(plugin?.status).toBe("loaded"); @@ -2015,6 +2030,223 @@ module.exports = { expect(registry.channels).toHaveLength(1); }); + it("can prefer setupEntry for configured channel loads during startup", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-runtime-preferred-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + startup: { + deferConfiguredChannelFullLoadUntilAfterListen: true, + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-runtime-preferred-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-runtime-preferred-test"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "setup-runtime-preferred-test", + register(api) { + api.registerChannel({ + plugin: { + id: "setup-runtime-preferred-test", + meta: { + id: "setup-runtime-preferred-test", + label: "Setup Runtime Preferred Test", + selectionLabel: "Setup Runtime Preferred Test", + docsPath: "/channels/setup-runtime-preferred-test", + blurb: "full entry should be deferred while startup is still cold", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: "setup-runtime-preferred-test", + meta: { + id: "setup-runtime-preferred-test", + label: "Setup Runtime Preferred Test", + selectionLabel: "Setup Runtime Preferred Test", + docsPath: "/channels/setup-runtime-preferred-test", + blurb: "setup runtime preferred", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + preferSetupRuntimeForChannelPlugins: true, + config: { + channels: { + "setup-runtime-preferred-test": { + enabled: true, + }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-preferred-test"], + }, + }, + }); + + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + expect(registry.channelSetups).toHaveLength(1); + expect(registry.channels).toHaveLength(1); + }); + + it("does not prefer setupEntry for configured channel loads without startup opt-in", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(makeTempDir(), "full-loaded.txt"); + const setupMarker = path.join(makeTempDir(), "setup-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-runtime-not-preferred-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-runtime-not-preferred-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-runtime-not-preferred-test"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "setup-runtime-not-preferred-test", + register(api) { + api.registerChannel({ + plugin: { + id: "setup-runtime-not-preferred-test", + meta: { + id: "setup-runtime-not-preferred-test", + label: "Setup Runtime Not Preferred Test", + selectionLabel: "Setup Runtime Not Preferred Test", + docsPath: "/channels/setup-runtime-not-preferred-test", + blurb: "full entry should still load without explicit startup opt-in", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: "setup-runtime-not-preferred-test", + meta: { + id: "setup-runtime-not-preferred-test", + label: "Setup Runtime Not Preferred Test", + selectionLabel: "Setup Runtime Not Preferred Test", + docsPath: "/channels/setup-runtime-not-preferred-test", + blurb: "setup runtime not preferred", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + preferSetupRuntimeForChannelPlugins: true, + config: { + channels: { + "setup-runtime-not-preferred-test": { + enabled: true, + }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-not-preferred-test"], + }, + }, + }); + + expect(fs.existsSync(fullMarker)).toBe(true); + expect(fs.existsSync(setupMarker)).toBe(false); + expect(registry.channelSetups).toHaveLength(1); + expect(registry.channels).toHaveLength(1); + }); + it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { useNoBundledPlugins(); const plugin = writePlugin({ @@ -2785,6 +3017,7 @@ module.exports = { it("preserves runtime reflection semantics when runtime is lazily initialized", () => { useNoBundledPlugins(); + const stateDir = makeTempDir(); const plugin = writePlugin({ id: "runtime-introspection", filename: "runtime-introspection.cjs", @@ -2803,12 +3036,17 @@ module.exports = { } };`, }); - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["runtime-introspection"], - }, - }); + const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => + loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["runtime-introspection"], + }, + options: { + onlyPluginIds: ["runtime-introspection"], + }, + }), + ); const record = registry.plugins.find((entry) => entry.id === "runtime-introspection"); expect(record?.status).toBe("loaded"); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index da9bcd3e993..dc3bf5139c6 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -54,12 +54,32 @@ export type PluginLoadOptions = { mode?: "full" | "validate"; onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; + /** + * Prefer `setupEntry` for configured channel plugins that explicitly opt in + * via package metadata because their setup entry covers the pre-listen startup surface. + */ + preferSetupRuntimeForChannelPlugins?: boolean; activate?: boolean; }; const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128; const registryCache = new Map(); const openAllowlistWarningCache = new Set(); +const LAZY_RUNTIME_REFLECTION_KEYS = [ + "version", + "config", + "subagent", + "system", + "media", + "tts", + "stt", + "tools", + "channel", + "events", + "logging", + "state", + "modelAuth", +] as const satisfies readonly (keyof PluginRuntime)[]; export function clearPluginLoaderCache(): void { registryCache.clear(); @@ -321,6 +341,7 @@ function buildCacheKey(params: { env: NodeJS.ProcessEnv; onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; + preferSetupRuntimeForChannelPlugins?: boolean; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, @@ -345,11 +366,13 @@ function buildCacheKey(params: { ); const scopeKey = JSON.stringify(params.onlyPluginIds ?? []); const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime"; + const startupChannelMode = + params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full"; return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ ...params.plugins, installs, loadPaths, - })}::${scopeKey}::${setupOnlyKey}`; + })}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}`; } function normalizeScopedPluginIds(ids?: string[]): string[] | undefined { @@ -430,12 +453,20 @@ function resolveSetupChannelRegistration(moduleExport: unknown): { function shouldLoadChannelPluginInSetupRuntime(params: { manifestChannels: string[]; setupSource?: string; + startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean; cfg: OpenClawConfig; env: NodeJS.ProcessEnv; + preferSetupRuntimeForChannelPlugins?: boolean; }): boolean { if (!params.setupSource || params.manifestChannels.length === 0) { return false; } + if ( + params.preferSetupRuntimeForChannelPlugins && + params.startupDeferConfiguredChannelFullLoadUntilAfterListen === true + ) { + return true; + } return !params.manifestChannels.some((channelId) => isChannelConfigured(params.cfg, channelId, params.env), ); @@ -785,6 +816,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds); const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null; const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; + const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true; const shouldActivate = options.activate !== false; // NOTE: `activate` is intentionally excluded from the cache key. All non-activating // (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they @@ -797,6 +829,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi env, onlyPluginIds, includeSetupOnlyChannelPlugins, + preferSetupRuntimeForChannelPlugins, }); const cacheEnabled = options.cache !== false; if (cacheEnabled) { @@ -870,6 +903,22 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi resolvedRuntime ??= resolveCreatePluginRuntime()(options.runtimeOptions); return resolvedRuntime; }; + const lazyRuntimeReflectionKeySet = new Set(LAZY_RUNTIME_REFLECTION_KEYS); + const resolveLazyRuntimeDescriptor = (prop: PropertyKey): PropertyDescriptor | undefined => { + if (!lazyRuntimeReflectionKeySet.has(prop)) { + return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop); + } + return { + configurable: true, + enumerable: true, + get() { + return Reflect.get(resolveRuntime() as object, prop); + }, + set(value: unknown) { + Reflect.set(resolveRuntime() as object, prop, value); + }, + }; + }; const runtime = new Proxy({} as PluginRuntime, { get(_target, prop, receiver) { return Reflect.get(resolveRuntime(), prop, receiver); @@ -878,13 +927,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi return Reflect.set(resolveRuntime(), prop, value, receiver); }, has(_target, prop) { - return Reflect.has(resolveRuntime(), prop); + return lazyRuntimeReflectionKeySet.has(prop) || Reflect.has(resolveRuntime(), prop); }, ownKeys() { - return Reflect.ownKeys(resolveRuntime() as object); + return [...LAZY_RUNTIME_REFLECTION_KEYS]; }, getOwnPropertyDescriptor(_target, prop) { - return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop); + return resolveLazyRuntimeDescriptor(prop); }, defineProperty(_target, prop, attributes) { return Reflect.defineProperty(resolveRuntime() as object, prop, attributes); @@ -1035,8 +1084,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi shouldLoadChannelPluginInSetupRuntime({ manifestChannels: manifestRecord.channels, setupSource: manifestRecord.setupSource, + startupDeferConfiguredChannelFullLoadUntilAfterListen: + manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, cfg, env, + preferSetupRuntimeForChannelPlugins, }) ? "setup-runtime" : "full" diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index ea646f38797..7a5c10d67f0 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -51,6 +51,7 @@ export type PluginManifestRecord = { rootDir: string; source: string; setupSource?: string; + startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean; manifestPath: string; schemaCacheKey?: string; configSchema?: Record; @@ -168,6 +169,9 @@ function buildRecord(params: { rootDir: params.candidate.rootDir, source: params.candidate.source, setupSource: params.candidate.setupSource, + startupDeferConfiguredChannelFullLoadUntilAfterListen: + params.candidate.packageManifest?.startup?.deferConfiguredChannelFullLoadUntilAfterListen === + true, manifestPath: params.manifestPath, schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index d330b982ce1..dd8615d7350 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -242,11 +242,20 @@ export type PluginPackageInstall = { defaultChoice?: "npm" | "local"; }; +export type OpenClawPackageStartup = { + /** + * Opt-in for channel plugins whose `setupEntry` fully covers the gateway + * startup surface needed before the server starts listening. + */ + deferConfiguredChannelFullLoadUntilAfterListen?: boolean; +}; + export type OpenClawPackageManifest = { extensions?: string[]; setupEntry?: string; channel?: PluginPackageChannel; install?: PluginPackageInstall; + startup?: OpenClawPackageStartup; }; export const DEFAULT_PLUGIN_ENTRY_CANDIDATES = [ diff --git a/src/plugins/provider-api-key-auth.runtime.ts b/src/plugins/provider-api-key-auth.runtime.ts new file mode 100644 index 00000000000..6909bd4cc2c --- /dev/null +++ b/src/plugins/provider-api-key-auth.runtime.ts @@ -0,0 +1,14 @@ +import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js"; +import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js"; +import { applyPrimaryModel } from "../commands/model-picker.js"; +import { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js"; +import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; + +export { + applyAuthProfileConfig, + applyPrimaryModel, + buildApiKeyCredential, + ensureApiKeyFromOptionEnvOrPrompt, + normalizeApiKeyInput, + validateApiKeyInput, +}; diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts index 75fa4afb77d..aa3805aea8f 100644 --- a/src/plugins/provider-api-key-auth.ts +++ b/src/plugins/provider-api-key-auth.ts @@ -1,9 +1,4 @@ -import { upsertAuthProfile } from "../agents/auth-profiles.js"; -import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js"; -import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js"; -import { applyPrimaryModel } from "../commands/model-picker.js"; -import { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js"; -import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; +import { upsertAuthProfile } from "../agents/auth-profiles/profiles.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SecretInput } from "../config/types.secrets.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; @@ -34,6 +29,15 @@ type ProviderApiKeyAuthMethodOptions = { applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig; }; +let providerApiKeyAuthRuntimePromise: + | Promise + | undefined; + +function loadProviderApiKeyAuthRuntime() { + providerApiKeyAuthRuntimePromise ??= import("./provider-api-key-auth.runtime.js"); + return providerApiKeyAuthRuntimePromise; +} + function resolveStringOption(opts: Record | undefined, optionKey: string) { return normalizeOptionalSecretInput(opts?.[optionKey]); } @@ -56,13 +60,14 @@ function resolveProfileIds(params: { return [resolveProfileId(params)]; } -function applyApiKeyConfig(params: { +async function applyApiKeyConfig(params: { ctx: ProviderAuthMethodNonInteractiveContext; providerId: string; profileIds: string[]; defaultModel?: string; applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig; }) { + const { applyAuthProfileConfig, applyPrimaryModel } = await loadProviderApiKeyAuthRuntime(); let next = params.ctx.config; for (const profileId of params.profileIds) { next = applyAuthProfileConfig(next, { @@ -92,6 +97,12 @@ export function createProviderApiKeyAuthMethod( let capturedSecretInput: SecretInput | undefined; let capturedCredential = false; let capturedMode: "plaintext" | "ref" | undefined; + const { + buildApiKeyCredential, + ensureApiKeyFromOptionEnvOrPrompt, + normalizeApiKeyInput, + validateApiKeyInput, + } = await loadProviderApiKeyAuthRuntime(); await ensureApiKeyFromOptionEnvOrPrompt({ token: flagValue ?? normalizeOptionalSecretInput(ctx.opts?.token), @@ -171,7 +182,7 @@ export function createProviderApiKeyAuthMethod( } } - return applyApiKeyConfig({ + return await applyApiKeyConfig({ ctx, providerId: params.providerId, profileIds, diff --git a/src/plugins/provider-catalog-metadata.ts b/src/plugins/provider-catalog-metadata.ts new file mode 100644 index 00000000000..123fef24289 --- /dev/null +++ b/src/plugins/provider-catalog-metadata.ts @@ -0,0 +1,97 @@ +import { normalizeProviderId } from "../agents/provider-id.js"; +import type { + ProviderAugmentModelCatalogContext, + ProviderBuiltInModelSuppressionContext, +} from "./types.js"; + +const OPENAI_PROVIDER_ID = "openai"; +const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; +const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; +const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]); + +function findCatalogTemplate(params: { + entries: ReadonlyArray<{ provider: string; id: string }>; + providerId: string; + templateIds: readonly string[]; +}) { + return params.templateIds + .map((templateId) => + params.entries.find( + (entry) => + entry.provider.toLowerCase() === params.providerId.toLowerCase() && + entry.id.toLowerCase() === templateId.toLowerCase(), + ), + ) + .find((entry) => entry !== undefined); +} + +export function resolveBundledProviderBuiltInModelSuppression( + context: ProviderBuiltInModelSuppressionContext, +) { + if ( + !SUPPRESSED_SPARK_PROVIDERS.has(normalizeProviderId(context.provider)) || + context.modelId.toLowerCase() !== OPENAI_DIRECT_SPARK_MODEL_ID + ) { + return undefined; + } + return { + suppress: true, + errorMessage: `Unknown model: ${context.provider}/${OPENAI_DIRECT_SPARK_MODEL_ID}. ${OPENAI_DIRECT_SPARK_MODEL_ID} is only supported via openai-codex OAuth. Use openai-codex/${OPENAI_DIRECT_SPARK_MODEL_ID}.`, + }; +} + +export function augmentBundledProviderCatalog( + context: ProviderAugmentModelCatalogContext, +): ProviderAugmentModelCatalogContext["entries"] { + const openAiGpt54Template = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_PROVIDER_ID, + templateIds: ["gpt-5.2"], + }); + const openAiGpt54ProTemplate = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_PROVIDER_ID, + templateIds: ["gpt-5.2-pro", "gpt-5.2"], + }); + const openAiCodexGpt54Template = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_CODEX_PROVIDER_ID, + templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"], + }); + const openAiCodexSparkTemplate = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_CODEX_PROVIDER_ID, + templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"], + }); + + return [ + openAiGpt54Template + ? { + ...openAiGpt54Template, + id: "gpt-5.4", + name: "gpt-5.4", + } + : undefined, + openAiGpt54ProTemplate + ? { + ...openAiGpt54ProTemplate, + id: "gpt-5.4-pro", + name: "gpt-5.4-pro", + } + : undefined, + openAiCodexGpt54Template + ? { + ...openAiCodexGpt54Template, + id: "gpt-5.4", + name: "gpt-5.4", + } + : undefined, + openAiCodexSparkTemplate + ? { + ...openAiCodexSparkTemplate, + id: OPENAI_DIRECT_SPARK_MODEL_ID, + name: OPENAI_DIRECT_SPARK_MODEL_ID, + } + : undefined, + ].filter((entry): entry is NonNullable => entry !== undefined); +} diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 266abe24556..07ee1794562 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -1,13 +1,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin, ProviderRuntimeModel } from "./types.js"; -const resolvePluginProvidersMock = vi.fn((_: unknown) => [] as ProviderPlugin[]); -const resolveOwningPluginIdsForProviderMock = vi.fn( - (_: unknown) => undefined as string[] | undefined, +type ResolvePluginProviders = typeof import("./providers.js").resolvePluginProviders; +type ResolveNonBundledProviderPluginIds = + typeof import("./providers.js").resolveNonBundledProviderPluginIds; +type ResolveOwningPluginIdsForProvider = + typeof import("./providers.js").resolveOwningPluginIdsForProvider; + +const resolvePluginProvidersMock = vi.fn((_) => [] as ProviderPlugin[]); +const resolveNonBundledProviderPluginIdsMock = vi.fn( + (_) => [] as string[], +); +const resolveOwningPluginIdsForProviderMock = vi.fn( + (_) => undefined as string[] | undefined, ); vi.mock("./providers.js", () => ({ resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), + resolveNonBundledProviderPluginIds: (params: unknown) => + resolveNonBundledProviderPluginIdsMock(params as never), resolveOwningPluginIdsForProvider: (params: unknown) => resolveOwningPluginIdsForProviderMock(params as never), })); @@ -30,6 +41,7 @@ import { normalizeProviderResolvedModelWithPlugin, prepareProviderDynamicModel, prepareProviderRuntimeAuth, + resetProviderRuntimeHookCacheForTest, refreshProviderOAuthCredentialWithPlugin, resolveProviderRuntimePlugin, runProviderDynamicModel, @@ -51,13 +63,17 @@ const MODEL: ProviderRuntimeModel = { describe("provider-runtime", () => { beforeEach(() => { + resetProviderRuntimeHookCacheForTest(); resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); + resolveNonBundledProviderPluginIdsMock.mockReset(); + resolveNonBundledProviderPluginIdsMock.mockReturnValue([]); resolveOwningPluginIdsForProviderMock.mockReset(); resolveOwningPluginIdsForProviderMock.mockReturnValue(undefined); }); it("matches providers by alias for runtime hook lookup", () => { + resolveOwningPluginIdsForProviderMock.mockReturnValue(["openrouter"]); resolvePluginProvidersMock.mockReturnValue([ { id: "openrouter", @@ -77,13 +93,35 @@ describe("provider-runtime", () => { ); expect(resolvePluginProvidersMock).toHaveBeenCalledWith( expect.objectContaining({ + onlyPluginIds: ["openrouter"], bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }), ); }); + it("skips plugin loading when the provider has no owning plugin", () => { + const plugin = resolveProviderRuntimePlugin({ provider: "anthropic" }); + + expect(plugin).toBeUndefined(); + expect(resolveOwningPluginIdsForProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "anthropic", + }), + ); + expect(resolvePluginProvidersMock).not.toHaveBeenCalled(); + }); + it("dispatches runtime hooks for the matched provider", async () => { + resolveOwningPluginIdsForProviderMock.mockImplementation((params) => { + if (params.provider === "demo") { + return ["demo"]; + } + if (params.provider === "openai") { + return ["openai"]; + } + return undefined; + }); const prepareDynamicModel = vi.fn(async () => undefined); const prepareRuntimeAuth = vi.fn(async () => ({ apiKey: "runtime-token", @@ -427,4 +465,44 @@ describe("provider-runtime", () => { expect(resolveUsageAuth).toHaveBeenCalledTimes(1); expect(fetchUsageSnapshot).toHaveBeenCalledTimes(1); }); + + it("resolves bundled catalog hooks without loading provider plugins", async () => { + expect( + resolveProviderBuiltInModelSuppression({ + env: process.env, + context: { + env: process.env, + provider: "openai", + modelId: "gpt-5.3-codex-spark", + }, + }), + ).toMatchObject({ + suppress: true, + }); + + await expect( + augmentModelCatalogWithProviderPlugins({ + env: process.env, + context: { + env: process.env, + entries: [ + { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, + { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, + { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + ], + }, + }), + ).resolves.toEqual([ + { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, + { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, + { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, + { + provider: "openai-codex", + id: "gpt-5.3-codex-spark", + name: "gpt-5.3-codex-spark", + }, + ]); + + expect(resolvePluginProvidersMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 189b5ccef0c..61a2a0c5792 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -1,7 +1,15 @@ import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-profiles/types.js"; -import { normalizeProviderId } from "../agents/model-selection.js"; +import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js"; +import { + augmentBundledProviderCatalog, + resolveBundledProviderBuiltInModelSuppression, +} from "./provider-catalog-metadata.js"; +import { + resolveNonBundledProviderPluginIds, + resolveOwningPluginIdsForProvider, + resolvePluginProviders, +} from "./providers.js"; import type { ProviderAuthDoctorHintContext, ProviderAugmentModelCatalogContext, @@ -33,19 +41,104 @@ function matchesProviderId(provider: ProviderPlugin, providerId: string): boolea return (provider.aliases ?? []).some((alias) => normalizeProviderId(alias) === normalized); } +let cachedHookProvidersWithoutConfig = new WeakMap< + NodeJS.ProcessEnv, + Map +>(); +let cachedHookProvidersByConfig = new WeakMap< + OpenClawConfig, + WeakMap> +>(); + +function resolveHookProviderCacheBucket(params: { + config?: OpenClawConfig; + env: NodeJS.ProcessEnv; +}) { + if (!params.config) { + let bucket = cachedHookProvidersWithoutConfig.get(params.env); + if (!bucket) { + bucket = new Map(); + cachedHookProvidersWithoutConfig.set(params.env, bucket); + } + return bucket; + } + + let envBuckets = cachedHookProvidersByConfig.get(params.config); + if (!envBuckets) { + envBuckets = new WeakMap>(); + cachedHookProvidersByConfig.set(params.config, envBuckets); + } + let bucket = envBuckets.get(params.env); + if (!bucket) { + bucket = new Map(); + envBuckets.set(params.env, bucket); + } + return bucket; +} + +function buildHookProviderCacheKey(params: { workspaceDir?: string; onlyPluginIds?: string[] }) { + return `${params.workspaceDir ?? ""}::${JSON.stringify(params.onlyPluginIds ?? [])}`; +} + +export function resetProviderRuntimeHookCacheForTest(): void { + cachedHookProvidersWithoutConfig = new WeakMap< + NodeJS.ProcessEnv, + Map + >(); + cachedHookProvidersByConfig = new WeakMap< + OpenClawConfig, + WeakMap> + >(); +} + function resolveProviderPluginsForHooks(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; onlyPluginIds?: string[]; }): ProviderPlugin[] { - return resolvePluginProviders({ + const env = params.env ?? process.env; + const cacheBucket = resolveHookProviderCacheBucket({ + config: params.config, + env, + }); + const cacheKey = buildHookProviderCacheKey({ + workspaceDir: params.workspaceDir, + onlyPluginIds: params.onlyPluginIds, + }); + const cached = cacheBucket.get(cacheKey); + if (cached) { + return cached; + } + const resolved = resolvePluginProviders({ ...params, + env, activate: false, cache: false, bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }); + cacheBucket.set(cacheKey, resolved); + return resolved; +} + +function resolveProviderPluginsForCatalogHooks(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderPlugin[] { + const onlyPluginIds = resolveNonBundledProviderPluginIds({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + if (onlyPluginIds.length === 0) { + return []; + } + return resolveProviderPluginsForHooks({ + ...params, + onlyPluginIds, + }); } export function resolveProviderRuntimePlugin(params: { @@ -54,14 +147,18 @@ export function resolveProviderRuntimePlugin(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin | undefined { + const owningPluginIds = resolveOwningPluginIdsForProvider({ + provider: params.provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + if (!owningPluginIds || owningPluginIds.length === 0) { + return undefined; + } return resolveProviderPluginsForHooks({ ...params, - onlyPluginIds: resolveOwningPluginIdsForProvider({ - provider: params.provider, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }), + onlyPluginIds: owningPluginIds, }).find((plugin) => matchesProviderId(plugin, params.provider)); } @@ -261,7 +358,11 @@ export function resolveProviderBuiltInModelSuppression(params: { env?: NodeJS.ProcessEnv; context: ProviderBuiltInModelSuppressionContext; }) { - for (const plugin of resolveProviderPluginsForHooks(params)) { + const bundledResult = resolveBundledProviderBuiltInModelSuppression(params.context); + if (bundledResult?.suppress) { + return bundledResult; + } + for (const plugin of resolveProviderPluginsForCatalogHooks(params)) { const result = plugin.suppressBuiltInModel?.(params.context); if (result?.suppress) { return result; @@ -276,8 +377,10 @@ export async function augmentModelCatalogWithProviderPlugins(params: { env?: NodeJS.ProcessEnv; context: ProviderAugmentModelCatalogContext; }) { - const supplemental = [] as ProviderAugmentModelCatalogContext["entries"]; - for (const plugin of resolveProviderPluginsForHooks(params)) { + const supplemental = [ + ...augmentBundledProviderCatalog(params.context), + ] as ProviderAugmentModelCatalogContext["entries"]; + for (const plugin of resolveProviderPluginsForCatalogHooks(params)) { const next = await plugin.augmentModelCatalog?.(params.context); if (!next || next.length === 0) { continue; diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 50530a3c051..bfc976a7abf 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -125,6 +125,34 @@ describe("resolvePluginProviders", () => { expect(allow).not.toContain("workspace-provider"); }); + it("scopes bundled provider compat expansion to the requested plugin ids", () => { + resolvePluginProviders({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + bundledProviderAllowlistCompat: true, + onlyPluginIds: ["moonshot"], + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["moonshot"], + config: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: expect.arrayContaining(["openrouter", "moonshot"]), + }), + }), + }), + ); + + const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0]; + const allow = call?.config?.plugins?.allow; + expect(allow).not.toContain("google"); + expect(allow).not.toContain("kilocode"); + }); + it("maps provider ids to owning plugin ids via manifests", () => { loadPluginManifestRegistryMock.mockReturnValue({ plugins: [ diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index f2a2b4497c9..35ef2703553 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -1,6 +1,7 @@ -import { normalizeProviderId } from "../agents/model-selection.js"; +import { normalizeProviderId } from "../agents/provider-id.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { withBundledPluginAllowlistCompat } from "./bundled-compat.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; @@ -62,14 +63,21 @@ function resolveBundledProviderCompatPluginIds(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; + onlyPluginIds?: string[]; }): string[] { + const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null; const registry = loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, }); return registry.plugins - .filter((plugin) => plugin.origin === "bundled" && plugin.providers.length > 0) + .filter( + (plugin) => + plugin.origin === "bundled" && + plugin.providers.length > 0 && + (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)), + ) .map((plugin) => plugin.id) .toSorted((left, right) => left.localeCompare(right)); } @@ -99,6 +107,33 @@ export function resolveOwningPluginIdsForProvider(params: { return pluginIds.length > 0 ? pluginIds : undefined; } +export function resolveNonBundledProviderPluginIds(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): string[] { + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const normalizedConfig = normalizePluginsConfig(params.config?.plugins); + return registry.plugins + .filter( + (plugin) => + plugin.origin !== "bundled" && + plugin.providers.length > 0 && + resolveEffectiveEnableState({ + id: plugin.id, + origin: plugin.origin, + config: normalizedConfig, + rootConfig: params.config, + }).enabled, + ) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); +} + export function resolvePluginProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; @@ -116,6 +151,7 @@ export function resolvePluginProviders(params: { config: params.config, workspaceDir: params.workspaceDir, env: params.env, + onlyPluginIds: params.onlyPluginIds, }) : []; const maybeAllowlistCompat = params.bundledProviderAllowlistCompat diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index 23b47d48eeb..80bb1aba736 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -84,8 +84,28 @@ import { createRuntimeTelegram } from "./runtime-telegram.js"; import { createRuntimeWhatsApp } from "./runtime-whatsapp.js"; import type { PluginRuntime } from "./types.js"; +function defineCachedValue( + target: T, + key: K, + create: () => unknown, +): void { + let cached: unknown; + let ready = false; + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + get() { + if (!ready) { + cached = create(); + ready = true; + } + return cached; + }, + }); +} + export function createRuntimeChannel(): PluginRuntime["channel"] { - return { + const channelRuntime = { text: { chunkByNewline, chunkMarkdownText, @@ -167,12 +187,6 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { shouldComputeCommandAuthorized, shouldHandleTextCommands, }, - discord: createRuntimeDiscord(), - slack: createRuntimeSlack(), - telegram: createRuntimeTelegram(), - signal: createRuntimeSignal(), - imessage: createRuntimeIMessage(), - whatsapp: createRuntimeWhatsApp(), line: { listLineAccountIds, resolveDefaultLineAccountId, @@ -190,5 +204,23 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { buildTemplateMessageFromPayload, monitorLineProvider, }, - }; + } satisfies Omit< + PluginRuntime["channel"], + "discord" | "slack" | "telegram" | "signal" | "imessage" | "whatsapp" + > & + Partial< + Pick< + PluginRuntime["channel"], + "discord" | "slack" | "telegram" | "signal" | "imessage" | "whatsapp" + > + >; + + defineCachedValue(channelRuntime, "discord", createRuntimeDiscord); + defineCachedValue(channelRuntime, "slack", createRuntimeSlack); + defineCachedValue(channelRuntime, "telegram", createRuntimeTelegram); + defineCachedValue(channelRuntime, "signal", createRuntimeSignal); + defineCachedValue(channelRuntime, "imessage", createRuntimeIMessage); + defineCachedValue(channelRuntime, "whatsapp", createRuntimeWhatsApp); + + return channelRuntime as PluginRuntime["channel"]; } diff --git a/src/plugins/runtime/runtime-discord-ops.runtime.ts b/src/plugins/runtime/runtime-discord-ops.runtime.ts new file mode 100644 index 00000000000..d10daac5a35 --- /dev/null +++ b/src/plugins/runtime/runtime-discord-ops.runtime.ts @@ -0,0 +1,21 @@ +export { auditDiscordChannelPermissions } from "../../../extensions/discord/src/audit.js"; +export { + listDiscordDirectoryGroupsLive, + listDiscordDirectoryPeersLive, +} from "../../../extensions/discord/src/directory-live.js"; +export { monitorDiscordProvider } from "../../../extensions/discord/src/monitor.js"; +export { probeDiscord } from "../../../extensions/discord/src/probe.js"; +export { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js"; +export { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js"; +export { + createThreadDiscord, + deleteMessageDiscord, + editChannelDiscord, + editMessageDiscord, + pinMessageDiscord, + sendDiscordComponentMessage, + sendMessageDiscord, + sendPollDiscord, + sendTypingDiscord, + unpinMessageDiscord, +} from "../../../extensions/discord/src/send.js"; diff --git a/src/plugins/runtime/runtime-discord.ts b/src/plugins/runtime/runtime-discord.ts index 6aadba32a9a..ae302ad0e5f 100644 --- a/src/plugins/runtime/runtime-discord.ts +++ b/src/plugins/runtime/runtime-discord.ts @@ -1,10 +1,4 @@ -import { auditDiscordChannelPermissions } from "../../../extensions/discord/src/audit.js"; import { discordMessageActions } from "../../../extensions/discord/src/channel-actions.js"; -import { - listDiscordDirectoryGroupsLive, - listDiscordDirectoryPeersLive, -} from "../../../extensions/discord/src/directory-live.js"; -import { monitorDiscordProvider } from "../../../extensions/discord/src/monitor.js"; import { getThreadBindingManager, resolveThreadBindingIdleTimeoutMs, @@ -15,37 +9,134 @@ import { setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, } from "../../../extensions/discord/src/monitor/thread-bindings.js"; -import { probeDiscord } from "../../../extensions/discord/src/probe.js"; -import { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js"; -import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js"; -import { - createThreadDiscord, - deleteMessageDiscord, - editChannelDiscord, - editMessageDiscord, - pinMessageDiscord, - sendDiscordComponentMessage, - sendMessageDiscord, - sendPollDiscord, - sendTypingDiscord, - unpinMessageDiscord, -} from "../../../extensions/discord/src/send.js"; import { createDiscordTypingLease } from "./runtime-discord-typing.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; +let runtimeDiscordOpsPromise: Promise | null = + null; + +function loadRuntimeDiscordOps() { + runtimeDiscordOpsPromise ??= import("./runtime-discord-ops.runtime.js"); + return runtimeDiscordOpsPromise; +} + +const auditChannelPermissionsLazy: PluginRuntimeChannel["discord"]["auditChannelPermissions"] = + async (...args) => { + const { auditDiscordChannelPermissions } = await loadRuntimeDiscordOps(); + return auditDiscordChannelPermissions(...args); + }; + +const listDirectoryGroupsLiveLazy: PluginRuntimeChannel["discord"]["listDirectoryGroupsLive"] = + async (...args) => { + const { listDiscordDirectoryGroupsLive } = await loadRuntimeDiscordOps(); + return listDiscordDirectoryGroupsLive(...args); + }; + +const listDirectoryPeersLiveLazy: PluginRuntimeChannel["discord"]["listDirectoryPeersLive"] = + async (...args) => { + const { listDiscordDirectoryPeersLive } = await loadRuntimeDiscordOps(); + return listDiscordDirectoryPeersLive(...args); + }; + +const probeDiscordLazy: PluginRuntimeChannel["discord"]["probeDiscord"] = async (...args) => { + const { probeDiscord } = await loadRuntimeDiscordOps(); + return probeDiscord(...args); +}; + +const resolveChannelAllowlistLazy: PluginRuntimeChannel["discord"]["resolveChannelAllowlist"] = + async (...args) => { + const { resolveDiscordChannelAllowlist } = await loadRuntimeDiscordOps(); + return resolveDiscordChannelAllowlist(...args); + }; + +const resolveUserAllowlistLazy: PluginRuntimeChannel["discord"]["resolveUserAllowlist"] = async ( + ...args +) => { + const { resolveDiscordUserAllowlist } = await loadRuntimeDiscordOps(); + return resolveDiscordUserAllowlist(...args); +}; + +const sendComponentMessageLazy: PluginRuntimeChannel["discord"]["sendComponentMessage"] = async ( + ...args +) => { + const { sendDiscordComponentMessage } = await loadRuntimeDiscordOps(); + return sendDiscordComponentMessage(...args); +}; + +const sendMessageDiscordLazy: PluginRuntimeChannel["discord"]["sendMessageDiscord"] = async ( + ...args +) => { + const { sendMessageDiscord } = await loadRuntimeDiscordOps(); + return sendMessageDiscord(...args); +}; + +const sendPollDiscordLazy: PluginRuntimeChannel["discord"]["sendPollDiscord"] = async (...args) => { + const { sendPollDiscord } = await loadRuntimeDiscordOps(); + return sendPollDiscord(...args); +}; + +const monitorDiscordProviderLazy: PluginRuntimeChannel["discord"]["monitorDiscordProvider"] = + async (...args) => { + const { monitorDiscordProvider } = await loadRuntimeDiscordOps(); + return monitorDiscordProvider(...args); + }; + +const sendTypingDiscordLazy: PluginRuntimeChannel["discord"]["typing"]["pulse"] = async ( + ...args +) => { + const { sendTypingDiscord } = await loadRuntimeDiscordOps(); + return sendTypingDiscord(...args); +}; + +const editMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["editMessage"] = + async (...args) => { + const { editMessageDiscord } = await loadRuntimeDiscordOps(); + return editMessageDiscord(...args); + }; + +const deleteMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["deleteMessage"] = + async (...args) => { + const { deleteMessageDiscord } = await loadRuntimeDiscordOps(); + return deleteMessageDiscord(...args); + }; + +const pinMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["pinMessage"] = + async (...args) => { + const { pinMessageDiscord } = await loadRuntimeDiscordOps(); + return pinMessageDiscord(...args); + }; + +const unpinMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["unpinMessage"] = + async (...args) => { + const { unpinMessageDiscord } = await loadRuntimeDiscordOps(); + return unpinMessageDiscord(...args); + }; + +const createThreadDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["createThread"] = + async (...args) => { + const { createThreadDiscord } = await loadRuntimeDiscordOps(); + return createThreadDiscord(...args); + }; + +const editChannelDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["editChannel"] = + async (...args) => { + const { editChannelDiscord } = await loadRuntimeDiscordOps(); + return editChannelDiscord(...args); + }; + export function createRuntimeDiscord(): PluginRuntimeChannel["discord"] { return { messageActions: discordMessageActions, - auditChannelPermissions: auditDiscordChannelPermissions, - listDirectoryGroupsLive: listDiscordDirectoryGroupsLive, - listDirectoryPeersLive: listDiscordDirectoryPeersLive, - probeDiscord, - resolveChannelAllowlist: resolveDiscordChannelAllowlist, - resolveUserAllowlist: resolveDiscordUserAllowlist, - sendComponentMessage: sendDiscordComponentMessage, - sendMessageDiscord, - sendPollDiscord, - monitorDiscordProvider, + auditChannelPermissions: auditChannelPermissionsLazy, + listDirectoryGroupsLive: listDirectoryGroupsLiveLazy, + listDirectoryPeersLive: listDirectoryPeersLiveLazy, + probeDiscord: probeDiscordLazy, + resolveChannelAllowlist: resolveChannelAllowlistLazy, + resolveUserAllowlist: resolveUserAllowlistLazy, + sendComponentMessage: sendComponentMessageLazy, + sendMessageDiscord: sendMessageDiscordLazy, + sendPollDiscord: sendPollDiscordLazy, + monitorDiscordProvider: monitorDiscordProviderLazy, threadBindings: { getManager: getThreadBindingManager, resolveIdleTimeoutMs: resolveThreadBindingIdleTimeoutMs, @@ -57,7 +148,7 @@ export function createRuntimeDiscord(): PluginRuntimeChannel["discord"] { unbindBySessionKey: unbindThreadBindingsBySessionKey, }, typing: { - pulse: sendTypingDiscord, + pulse: sendTypingDiscordLazy, start: async ({ channelId, accountId, cfg, intervalMs }) => await createDiscordTypingLease({ channelId, @@ -65,16 +156,16 @@ export function createRuntimeDiscord(): PluginRuntimeChannel["discord"] { cfg, intervalMs, pulse: async ({ channelId, accountId, cfg }) => - void (await sendTypingDiscord(channelId, { accountId, cfg })), + void (await sendTypingDiscordLazy(channelId, { accountId, cfg })), }), }, conversationActions: { - editMessage: editMessageDiscord, - deleteMessage: deleteMessageDiscord, - pinMessage: pinMessageDiscord, - unpinMessage: unpinMessageDiscord, - createThread: createThreadDiscord, - editChannel: editChannelDiscord, + editMessage: editMessageDiscordLazy, + deleteMessage: deleteMessageDiscordLazy, + pinMessage: pinMessageDiscordLazy, + unpinMessage: unpinMessageDiscordLazy, + createThread: createThreadDiscordLazy, + editChannel: editChannelDiscordLazy, }, }; } diff --git a/src/plugins/runtime/runtime-slack-ops.runtime.ts b/src/plugins/runtime/runtime-slack-ops.runtime.ts new file mode 100644 index 00000000000..e22662c3b7f --- /dev/null +++ b/src/plugins/runtime/runtime-slack-ops.runtime.ts @@ -0,0 +1,10 @@ +export { + listSlackDirectoryGroupsLive, + listSlackDirectoryPeersLive, +} from "../../../extensions/slack/src/directory-live.js"; +export { monitorSlackProvider } from "../../../extensions/slack/src/index.js"; +export { probeSlack } from "../../../extensions/slack/src/probe.js"; +export { resolveSlackChannelAllowlist } from "../../../extensions/slack/src/resolve-channels.js"; +export { resolveSlackUserAllowlist } from "../../../extensions/slack/src/resolve-users.js"; +export { sendMessageSlack } from "../../../extensions/slack/src/send.js"; +export { handleSlackAction } from "../../agents/tools/slack-actions.js"; diff --git a/src/plugins/runtime/runtime-slack.ts b/src/plugins/runtime/runtime-slack.ts index 095b14ec9c7..9579aed4c1b 100644 --- a/src/plugins/runtime/runtime-slack.ts +++ b/src/plugins/runtime/runtime-slack.ts @@ -1,24 +1,71 @@ -import { - listSlackDirectoryGroupsLive, - listSlackDirectoryPeersLive, -} from "../../../extensions/slack/src/directory-live.js"; -import { monitorSlackProvider } from "../../../extensions/slack/src/index.js"; -import { probeSlack } from "../../../extensions/slack/src/probe.js"; -import { resolveSlackChannelAllowlist } from "../../../extensions/slack/src/resolve-channels.js"; -import { resolveSlackUserAllowlist } from "../../../extensions/slack/src/resolve-users.js"; -import { sendMessageSlack } from "../../../extensions/slack/src/send.js"; -import { handleSlackAction } from "../../agents/tools/slack-actions.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; +let runtimeSlackOpsPromise: Promise | null = null; + +function loadRuntimeSlackOps() { + runtimeSlackOpsPromise ??= import("./runtime-slack-ops.runtime.js"); + return runtimeSlackOpsPromise; +} + +const listDirectoryGroupsLiveLazy: PluginRuntimeChannel["slack"]["listDirectoryGroupsLive"] = + async (...args) => { + const { listSlackDirectoryGroupsLive } = await loadRuntimeSlackOps(); + return listSlackDirectoryGroupsLive(...args); + }; + +const listDirectoryPeersLiveLazy: PluginRuntimeChannel["slack"]["listDirectoryPeersLive"] = async ( + ...args +) => { + const { listSlackDirectoryPeersLive } = await loadRuntimeSlackOps(); + return listSlackDirectoryPeersLive(...args); +}; + +const probeSlackLazy: PluginRuntimeChannel["slack"]["probeSlack"] = async (...args) => { + const { probeSlack } = await loadRuntimeSlackOps(); + return probeSlack(...args); +}; + +const resolveChannelAllowlistLazy: PluginRuntimeChannel["slack"]["resolveChannelAllowlist"] = + async (...args) => { + const { resolveSlackChannelAllowlist } = await loadRuntimeSlackOps(); + return resolveSlackChannelAllowlist(...args); + }; + +const resolveUserAllowlistLazy: PluginRuntimeChannel["slack"]["resolveUserAllowlist"] = async ( + ...args +) => { + const { resolveSlackUserAllowlist } = await loadRuntimeSlackOps(); + return resolveSlackUserAllowlist(...args); +}; + +const sendMessageSlackLazy: PluginRuntimeChannel["slack"]["sendMessageSlack"] = async (...args) => { + const { sendMessageSlack } = await loadRuntimeSlackOps(); + return sendMessageSlack(...args); +}; + +const monitorSlackProviderLazy: PluginRuntimeChannel["slack"]["monitorSlackProvider"] = async ( + ...args +) => { + const { monitorSlackProvider } = await loadRuntimeSlackOps(); + return monitorSlackProvider(...args); +}; + +const handleSlackActionLazy: PluginRuntimeChannel["slack"]["handleSlackAction"] = async ( + ...args +) => { + const { handleSlackAction } = await loadRuntimeSlackOps(); + return handleSlackAction(...args); +}; + export function createRuntimeSlack(): PluginRuntimeChannel["slack"] { return { - listDirectoryGroupsLive: listSlackDirectoryGroupsLive, - listDirectoryPeersLive: listSlackDirectoryPeersLive, - probeSlack, - resolveChannelAllowlist: resolveSlackChannelAllowlist, - resolveUserAllowlist: resolveSlackUserAllowlist, - sendMessageSlack, - monitorSlackProvider, - handleSlackAction, + listDirectoryGroupsLive: listDirectoryGroupsLiveLazy, + listDirectoryPeersLive: listDirectoryPeersLiveLazy, + probeSlack: probeSlackLazy, + resolveChannelAllowlist: resolveChannelAllowlistLazy, + resolveUserAllowlist: resolveUserAllowlistLazy, + sendMessageSlack: sendMessageSlackLazy, + monitorSlackProvider: monitorSlackProviderLazy, + handleSlackAction: handleSlackActionLazy, }; } diff --git a/src/plugins/runtime/runtime-telegram-ops.runtime.ts b/src/plugins/runtime/runtime-telegram-ops.runtime.ts new file mode 100644 index 00000000000..dc463625b4f --- /dev/null +++ b/src/plugins/runtime/runtime-telegram-ops.runtime.ts @@ -0,0 +1,18 @@ +export { + auditTelegramGroupMembership, + collectTelegramUnmentionedGroupIds, +} from "../../../extensions/telegram/src/audit.js"; +export { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js"; +export { probeTelegram } from "../../../extensions/telegram/src/probe.js"; +export { + deleteMessageTelegram, + editMessageReplyMarkupTelegram, + editMessageTelegram, + pinMessageTelegram, + renameForumTopicTelegram, + sendMessageTelegram, + sendPollTelegram, + sendTypingTelegram, + unpinMessageTelegram, +} from "../../../extensions/telegram/src/send.js"; +export { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index 9481c718565..22061a7e00d 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -1,21 +1,5 @@ -import { - auditTelegramGroupMembership, - collectTelegramUnmentionedGroupIds, -} from "../../../extensions/telegram/src/audit.js"; +import { collectTelegramUnmentionedGroupIds } from "../../../extensions/telegram/src/audit.js"; import { telegramMessageActions } from "../../../extensions/telegram/src/channel-actions.js"; -import { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js"; -import { probeTelegram } from "../../../extensions/telegram/src/probe.js"; -import { - deleteMessageTelegram, - editMessageReplyMarkupTelegram, - editMessageTelegram, - pinMessageTelegram, - renameForumTopicTelegram, - sendMessageTelegram, - sendPollTelegram, - sendTypingTelegram, - unpinMessageTelegram, -} from "../../../extensions/telegram/src/send.js"; import { setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey, @@ -24,22 +8,105 @@ import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js" import { createTelegramTypingLease } from "./runtime-telegram-typing.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; +let runtimeTelegramOpsPromise: Promise | null = + null; + +function loadRuntimeTelegramOps() { + runtimeTelegramOpsPromise ??= import("./runtime-telegram-ops.runtime.js"); + return runtimeTelegramOpsPromise; +} + +const auditGroupMembershipLazy: PluginRuntimeChannel["telegram"]["auditGroupMembership"] = async ( + ...args +) => { + const { auditTelegramGroupMembership } = await loadRuntimeTelegramOps(); + return auditTelegramGroupMembership(...args); +}; + +const probeTelegramLazy: PluginRuntimeChannel["telegram"]["probeTelegram"] = async (...args) => { + const { probeTelegram } = await loadRuntimeTelegramOps(); + return probeTelegram(...args); +}; + +const sendMessageTelegramLazy: PluginRuntimeChannel["telegram"]["sendMessageTelegram"] = async ( + ...args +) => { + const { sendMessageTelegram } = await loadRuntimeTelegramOps(); + return sendMessageTelegram(...args); +}; + +const sendPollTelegramLazy: PluginRuntimeChannel["telegram"]["sendPollTelegram"] = async ( + ...args +) => { + const { sendPollTelegram } = await loadRuntimeTelegramOps(); + return sendPollTelegram(...args); +}; + +const monitorTelegramProviderLazy: PluginRuntimeChannel["telegram"]["monitorTelegramProvider"] = + async (...args) => { + const { monitorTelegramProvider } = await loadRuntimeTelegramOps(); + return monitorTelegramProvider(...args); + }; + +const sendTypingTelegramLazy: PluginRuntimeChannel["telegram"]["typing"]["pulse"] = async ( + ...args +) => { + const { sendTypingTelegram } = await loadRuntimeTelegramOps(); + return sendTypingTelegram(...args); +}; + +const editMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["editMessage"] = + async (...args) => { + const { editMessageTelegram } = await loadRuntimeTelegramOps(); + return editMessageTelegram(...args); + }; + +const editMessageReplyMarkupTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["editReplyMarkup"] = + async (...args) => { + const { editMessageReplyMarkupTelegram } = await loadRuntimeTelegramOps(); + return editMessageReplyMarkupTelegram(...args); + }; + +const deleteMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["deleteMessage"] = + async (...args) => { + const { deleteMessageTelegram } = await loadRuntimeTelegramOps(); + return deleteMessageTelegram(...args); + }; + +const renameForumTopicTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["renameTopic"] = + async (...args) => { + const { renameForumTopicTelegram } = await loadRuntimeTelegramOps(); + return renameForumTopicTelegram(...args); + }; + +const pinMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["pinMessage"] = + async (...args) => { + const { pinMessageTelegram } = await loadRuntimeTelegramOps(); + return pinMessageTelegram(...args); + }; + +const unpinMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["unpinMessage"] = + async (...args) => { + const { unpinMessageTelegram } = await loadRuntimeTelegramOps(); + return unpinMessageTelegram(...args); + }; + export function createRuntimeTelegram(): PluginRuntimeChannel["telegram"] { return { - auditGroupMembership: auditTelegramGroupMembership, + auditGroupMembership: auditGroupMembershipLazy, collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds, - probeTelegram, + probeTelegram: probeTelegramLazy, resolveTelegramToken, - sendMessageTelegram, - sendPollTelegram, - monitorTelegramProvider, + sendMessageTelegram: sendMessageTelegramLazy, + sendPollTelegram: sendPollTelegramLazy, + monitorTelegramProvider: monitorTelegramProviderLazy, messageActions: telegramMessageActions, threadBindings: { setIdleTimeoutBySessionKey: setTelegramThreadBindingIdleTimeoutBySessionKey, setMaxAgeBySessionKey: setTelegramThreadBindingMaxAgeBySessionKey, }, typing: { - pulse: sendTypingTelegram, + pulse: sendTypingTelegramLazy, start: async ({ to, accountId, cfg, intervalMs, messageThreadId }) => await createTelegramTypingLease({ to, @@ -48,7 +115,7 @@ export function createRuntimeTelegram(): PluginRuntimeChannel["telegram"] { intervalMs, messageThreadId, pulse: async ({ to, accountId, cfg, messageThreadId }) => - await sendTypingTelegram(to, { + await sendTypingTelegramLazy(to, { accountId, cfg, messageThreadId, @@ -56,14 +123,14 @@ export function createRuntimeTelegram(): PluginRuntimeChannel["telegram"] { }), }, conversationActions: { - editMessage: editMessageTelegram, - editReplyMarkup: editMessageReplyMarkupTelegram, + editMessage: editMessageTelegramLazy, + editReplyMarkup: editMessageReplyMarkupTelegramLazy, clearReplyMarkup: async (chatIdInput, messageIdInput, opts = {}) => - await editMessageReplyMarkupTelegram(chatIdInput, messageIdInput, [], opts), - deleteMessage: deleteMessageTelegram, - renameTopic: renameForumTopicTelegram, - pinMessage: pinMessageTelegram, - unpinMessage: unpinMessageTelegram, + await editMessageReplyMarkupTelegramLazy(chatIdInput, messageIdInput, [], opts), + deleteMessage: deleteMessageTelegramLazy, + renameTopic: renameForumTopicTelegramLazy, + pinMessage: pinMessageTelegramLazy, + unpinMessage: unpinMessageTelegramLazy, }, }; } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index b9b6e801214..0c817a99cf8 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -849,6 +849,10 @@ export type WebSearchProviderPlugin = { createTool: (ctx: WebSearchProviderContext) => WebSearchProviderToolDefinition | null; }; +export type PluginWebSearchProviderEntry = WebSearchProviderPlugin & { + pluginId: string; +}; + export type OpenClawPluginGatewayMethod = { method: string; handler: GatewayRequestHandler; diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index 26c9f847bf9..52e326ddc04 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -1,64 +1,22 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { resolvePluginWebSearchProviders } from "./web-search-providers.js"; -const loadOpenClawPluginsMock = vi.fn(); - -vi.mock("./loader.js", () => ({ - loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), -})); - describe("resolvePluginWebSearchProviders", () => { - beforeEach(() => { - loadOpenClawPluginsMock.mockReset(); - loadOpenClawPluginsMock.mockReturnValue({ - webSearchProviders: [ - { - pluginId: "google", - provider: { - id: "gemini", - label: "Gemini", - hint: "hint", - envVars: ["GEMINI_API_KEY"], - placeholder: "AIza...", - signupUrl: "https://example.com", - autoDetectOrder: 20, - }, - }, - { - pluginId: "brave", - provider: { - id: "brave", - label: "Brave", - hint: "hint", - envVars: ["BRAVE_API_KEY"], - placeholder: "BSA...", - signupUrl: "https://example.com", - autoDetectOrder: 10, - }, - }, - ], - }); - }); + it("returns bundled providers in auto-detect order", () => { + const providers = resolvePluginWebSearchProviders({}); - it("forwards an explicit env to plugin loading", () => { - const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv; - - const providers = resolvePluginWebSearchProviders({ - workspaceDir: "/workspace/explicit", - env, - }); - - expect(providers.map((provider) => provider.id)).toEqual(["brave", "gemini"]); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceDir: "/workspace/explicit", - env, - }), - ); + expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ + "brave:brave", + "google:gemini", + "xai:grok", + "moonshot:kimi", + "perplexity:perplexity", + "firecrawl:firecrawl", + ]); }); it("can augment restrictive allowlists for bundled compatibility", () => { - resolvePluginWebSearchProviders({ + const providers = resolvePluginWebSearchProviders({ config: { plugins: { allow: ["openrouter"], @@ -67,49 +25,30 @@ describe("resolvePluginWebSearchProviders", () => { bundledAllowlistCompat: true, }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: expect.arrayContaining(["openrouter", "brave", "perplexity"]), - }), - }), - }), - ); + expect(providers.map((provider) => provider.pluginId)).toEqual([ + "brave", + "google", + "xai", + "moonshot", + "perplexity", + "firecrawl", + ]); }); - it("auto-enables bundled web search provider plugins when entries are missing", () => { - resolvePluginWebSearchProviders({ + it("does not return bundled providers excluded by a restrictive allowlist without compat", () => { + const providers = resolvePluginWebSearchProviders({ config: { plugins: { - entries: { - openrouter: { enabled: true }, - }, + allow: ["openrouter"], }, }, }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - entries: expect.objectContaining({ - openrouter: { enabled: true }, - brave: { enabled: true }, - firecrawl: { enabled: true }, - google: { enabled: true }, - moonshot: { enabled: true }, - perplexity: { enabled: true }, - xai: { enabled: true }, - }), - }), - }), - }), - ); + expect(providers).toEqual([]); }); it("preserves explicit bundled provider entry state", () => { - resolvePluginWebSearchProviders({ + const providers = resolvePluginWebSearchProviders({ config: { plugins: { entries: { @@ -119,16 +58,18 @@ describe("resolvePluginWebSearchProviders", () => { }, }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - entries: expect.objectContaining({ - perplexity: { enabled: false }, - }), - }), - }), - }), - ); + expect(providers.map((provider) => provider.pluginId)).not.toContain("perplexity"); + }); + + it("returns no providers when plugins are globally disabled", () => { + const providers = resolvePluginWebSearchProviders({ + config: { + plugins: { + enabled: false, + }, + }, + }); + + expect(providers).toEqual([]); }); }); diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index c44bb6f2a93..97b6d9ee022 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -1,13 +1,18 @@ -import { createSubsystemLogger } from "../logging/subsystem.js"; +import { createFirecrawlWebSearchProvider } from "../../extensions/firecrawl/src/firecrawl-search-provider.js"; +import { + createPluginBackedWebSearchProvider, + getScopedCredentialValue, + getTopLevelCredentialValue, + setScopedCredentialValue, + setTopLevelCredentialValue, +} from "../agents/tools/web-search-plugin-factory.js"; import { withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, } from "./bundled-compat.js"; -import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; -import { createPluginLoaderLogger } from "./logger.js"; -import type { WebSearchProviderPlugin } from "./types.js"; - -const log = createSubsystemLogger("plugins"); +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; +import type { PluginLoadOptions } from "./loader.js"; +import type { PluginWebSearchProviderEntry } from "./types.js"; const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "brave", @@ -18,12 +23,98 @@ const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "xai", ] as const; +const BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY = [ + { + pluginId: "brave", + provider: createPluginBackedWebSearchProvider({ + id: "brave", + label: "Brave Search", + hint: "Structured results · country/language/time filters", + envVars: ["BRAVE_API_KEY"], + placeholder: "BSA...", + signupUrl: "https://brave.com/search/api/", + docsUrl: "https://docs.openclaw.ai/brave-search", + autoDetectOrder: 10, + getCredentialValue: getTopLevelCredentialValue, + setCredentialValue: setTopLevelCredentialValue, + }), + }, + { + pluginId: "google", + provider: createPluginBackedWebSearchProvider({ + id: "gemini", + label: "Gemini (Google Search)", + hint: "Google Search grounding · AI-synthesized", + envVars: ["GEMINI_API_KEY"], + placeholder: "AIza...", + signupUrl: "https://aistudio.google.com/apikey", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 20, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "gemini", value), + }), + }, + { + pluginId: "xai", + provider: createPluginBackedWebSearchProvider({ + id: "grok", + label: "Grok (xAI)", + hint: "xAI web-grounded responses", + envVars: ["XAI_API_KEY"], + placeholder: "xai-...", + signupUrl: "https://console.x.ai/", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 30, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "grok"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "grok", value), + }), + }, + { + pluginId: "moonshot", + provider: createPluginBackedWebSearchProvider({ + id: "kimi", + label: "Kimi (Moonshot)", + hint: "Moonshot web search", + envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], + placeholder: "sk-...", + signupUrl: "https://platform.moonshot.cn/", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 40, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "kimi", value), + }), + }, + { + pluginId: "perplexity", + provider: createPluginBackedWebSearchProvider({ + id: "perplexity", + label: "Perplexity Search", + hint: "Structured results · domain/country/language/time filters", + envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], + placeholder: "pplx-...", + signupUrl: "https://www.perplexity.ai/settings/api", + docsUrl: "https://docs.openclaw.ai/perplexity", + autoDetectOrder: 50, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "perplexity", value), + }), + }, + { + pluginId: "firecrawl", + provider: createFirecrawlWebSearchProvider(), + }, +] as const; + export function resolvePluginWebSearchProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; bundledAllowlistCompat?: boolean; -}): WebSearchProviderPlugin[] { +}): PluginWebSearchProviderEntry[] { const allowlistCompat = params.bundledAllowlistCompat ? withBundledPluginAllowlistCompat({ config: params.config, @@ -34,17 +125,17 @@ export function resolvePluginWebSearchProviders(params: { config: allowlistCompat, pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, }); - const registry = loadOpenClawPlugins({ - config, - workspaceDir: params.workspaceDir, - env: params.env, - logger: createPluginLoaderLogger(log), - activate: false, - cache: false, - onlyPluginIds: [...BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS], - }); + const normalizedPlugins = normalizePluginsConfig(config?.plugins); - return registry.webSearchProviders + return BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY.filter( + ({ pluginId }) => + resolveEffectiveEnableState({ + id: pluginId, + origin: "bundled", + config: normalizedPlugins, + rootConfig: config, + }).enabled, + ) .map((entry) => ({ ...entry.provider, pluginId: entry.pluginId, diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index 55d14c7e6d0..d71c98ac389 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { runSecretsApply } from "./apply.js"; import type { SecretsApplyPlan } from "./plan.js"; +import { clearSecretsRuntimeSnapshot } from "./runtime.js"; const OPENAI_API_KEY_ENV_REF = { source: "env", @@ -173,11 +174,13 @@ describe("secrets apply", () => { let fixture: ApplyFixture; beforeEach(async () => { + clearSecretsRuntimeSnapshot(); fixture = await createApplyFixture(); await seedDefaultApplyFixture(fixture); }); afterEach(async () => { + clearSecretsRuntimeSnapshot(); await fs.rm(fixture.rootDir, { recursive: true, force: true }); }); diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index 57e3e955066..c67f6af6573 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import * as webSearchProviders from "../plugins/web-search-providers.js"; import * as secretResolve from "./resolve.js"; import { createResolverContext } from "./runtime-shared.js"; import { resolveRuntimeWebTools } from "./runtime-web-tools.js"; @@ -88,6 +89,32 @@ describe("runtime web tools resolution", () => { vi.restoreAllMocks(); }); + it("skips loading web search providers when search config is absent", async () => { + const providerSpy = vi.spyOn(webSearchProviders, "resolvePluginWebSearchProviders"); + + const { metadata } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + fetch: { + firecrawl: { + apiKey: { source: "env", provider: "default", id: "FIRECRAWL_API_KEY_REF" }, + }, + }, + }, + }, + }), + env: { + FIRECRAWL_API_KEY: "firecrawl-runtime-key", // pragma: allowlist secret + }, + }); + + expect(providerSpy).not.toHaveBeenCalled(); + expect(metadata.search.providerSource).toBe("none"); + expect(metadata.fetch.firecrawl.active).toBe(true); + expect(metadata.fetch.firecrawl.apiKeySource).toBe("env"); + }); + it.each([ { provider: "brave" as const, diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 71b346cc462..fd32ecedf93 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -315,11 +315,13 @@ export async function resolveRuntimeWebTools(params: { const tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined; const web = isRecord(tools?.web) ? tools.web : undefined; const search = isRecord(web?.search) ? web.search : undefined; - const providers = resolvePluginWebSearchProviders({ - config: params.sourceConfig, - env: params.context.env, - bundledAllowlistCompat: true, - }); + const providers = search + ? resolvePluginWebSearchProviders({ + config: params.sourceConfig, + env: params.context.env, + bundledAllowlistCompat: true, + }) + : []; const searchMetadata: RuntimeWebSearchMetadata = { providerSource: "none", diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index 903fe5a6d24..ed85cde5a8d 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -79,11 +79,14 @@ function clearActiveSecretsRuntimeState(): void { clearRuntimeAuthProfileStoreSnapshots(); } -function collectCandidateAgentDirs(config: OpenClawConfig): string[] { +function collectCandidateAgentDirs( + config: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): string[] { const dirs = new Set(); - dirs.add(resolveUserPath(resolveOpenClawAgentDir())); + dirs.add(resolveUserPath(resolveOpenClawAgentDir(env), env)); for (const agentId of listAgentIds(config)) { - dirs.add(resolveUserPath(resolveAgentDir(config, agentId))); + dirs.add(resolveUserPath(resolveAgentDir(config, agentId, env), env)); } return [...dirs]; } @@ -92,7 +95,7 @@ function resolveRefreshAgentDirs( config: OpenClawConfig, context: SecretsRuntimeRefreshContext, ): string[] { - const configDerived = collectCandidateAgentDirs(config); + const configDerived = collectCandidateAgentDirs(config, context.env); if (!context.explicitAgentDirs || context.explicitAgentDirs.length === 0) { return configDerived; } @@ -119,8 +122,12 @@ export async function prepareSecretsRuntimeSnapshot(params: { const loadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreForSecretsRuntime; const candidateDirs = params.agentDirs?.length - ? [...new Set(params.agentDirs.map((entry) => resolveUserPath(entry)))] - : collectCandidateAgentDirs(resolvedConfig); + ? [ + ...new Set( + params.agentDirs.map((entry) => resolveUserPath(entry, params.env ?? process.env)), + ), + ] + : collectCandidateAgentDirs(resolvedConfig, params.env ?? process.env); const authStores: Array<{ agentDir: string; store: AuthProfileStore }> = []; for (const agentDir of candidateDirs) { diff --git a/src/test-utils/runtime-group-policy-contract.ts b/src/test-utils/runtime-group-policy-contract.ts deleted file mode 100644 index 65a0e0b8ef3..00000000000 --- a/src/test-utils/runtime-group-policy-contract.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect, it } from "vitest"; -import type { - ResolveProviderRuntimeGroupPolicyParams, - RuntimeGroupPolicyResolution, -} from "../config/runtime-group-policy.js"; -import type { GroupPolicy } from "../config/types.base.js"; - -type RuntimeGroupPolicyResolver = ( - params: ResolveProviderRuntimeGroupPolicyParams, -) => RuntimeGroupPolicyResolution; - -export function installProviderRuntimeGroupPolicyFallbackSuite(params: { - configuredLabel: string; - defaultGroupPolicyUnderTest: GroupPolicy; - missingConfigLabel: string; - missingDefaultLabel: string; - resolve: RuntimeGroupPolicyResolver; -}) { - it(params.missingConfigLabel, () => { - const resolved = params.resolve({ - providerConfigPresent: false, - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); - }); - - it(params.configuredLabel, () => { - const resolved = params.resolve({ - providerConfigPresent: true, - }); - expect(resolved.groupPolicy).toBe("open"); - expect(resolved.providerMissingFallbackApplied).toBe(false); - }); - - it(params.missingDefaultLabel, () => { - const resolved = params.resolve({ - providerConfigPresent: false, - defaultGroupPolicy: params.defaultGroupPolicyUnderTest, - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); - }); -} diff --git a/src/test-utils/send-payload-contract.ts b/src/test-utils/send-payload-contract.ts deleted file mode 100644 index 5e78e406a74..00000000000 --- a/src/test-utils/send-payload-contract.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { expect, it, type Mock } from "vitest"; - -type PayloadLike = { - mediaUrl?: string; - mediaUrls?: string[]; - text?: string; -}; - -type SendResultLike = { - messageId: string; - [key: string]: unknown; -}; - -type ChunkingMode = - | { - longTextLength: number; - maxChunkLength: number; - mode: "split"; - } - | { - longTextLength: number; - mode: "passthrough"; - }; - -export function installSendPayloadContractSuite(params: { - channel: string; - chunking: ChunkingMode; - createHarness: (params: { payload: PayloadLike; sendResults?: SendResultLike[] }) => { - run: () => Promise>; - sendMock: Mock; - to: string; - }; -}) { - it("text-only delegates to sendText", async () => { - const { run, sendMock, to } = params.createHarness({ - payload: { text: "hello" }, - }); - const result = await run(); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock).toHaveBeenCalledWith(to, "hello", expect.any(Object)); - expect(result).toMatchObject({ channel: params.channel }); - }); - - it("single media delegates to sendMedia", async () => { - const { run, sendMock, to } = params.createHarness({ - payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" }, - }); - const result = await run(); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock).toHaveBeenCalledWith( - to, - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: params.channel }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - const { run, sendMock, to } = params.createHarness({ - payload: { - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - }, - sendResults: [{ messageId: "m-1" }, { messageId: "m-2" }], - }); - const result = await run(); - - expect(sendMock).toHaveBeenCalledTimes(2); - expect(sendMock).toHaveBeenNthCalledWith( - 1, - to, - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(sendMock).toHaveBeenNthCalledWith( - 2, - to, - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: params.channel, messageId: "m-2" }); - }); - - it("empty payload returns no-op", async () => { - const { run, sendMock } = params.createHarness({ payload: {} }); - const result = await run(); - - expect(sendMock).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: params.channel, messageId: "" }); - }); - - if (params.chunking.mode === "passthrough") { - it("text exceeding chunk limit is sent as-is when chunker is null", async () => { - const text = "a".repeat(params.chunking.longTextLength); - const { run, sendMock, to } = params.createHarness({ payload: { text } }); - const result = await run(); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock).toHaveBeenCalledWith(to, text, expect.any(Object)); - expect(result).toMatchObject({ channel: params.channel }); - }); - return; - } - - const chunking = params.chunking; - - it("chunking splits long text", async () => { - const text = "a".repeat(chunking.longTextLength); - const { run, sendMock } = params.createHarness({ - payload: { text }, - sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }], - }); - const result = await run(); - - expect(sendMock.mock.calls.length).toBeGreaterThanOrEqual(2); - for (const call of sendMock.mock.calls) { - expect((call[1] as string).length).toBeLessThanOrEqual(chunking.maxChunkLength); - } - expect(result).toMatchObject({ channel: params.channel }); - }); -} - -export function primeSendMock( - sendMock: Mock, - fallbackResult: Record, - sendResults: SendResultLike[] = [], -) { - sendMock.mockReset(); - if (sendResults.length === 0) { - sendMock.mockResolvedValue(fallbackResult); - return; - } - for (const result of sendResults) { - sendMock.mockResolvedValueOnce(result); - } -} diff --git a/test/helpers/inbound-contract.ts b/test/helpers/inbound-contract.ts deleted file mode 100644 index 4ac4c2cc516..00000000000 --- a/test/helpers/inbound-contract.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect } from "vitest"; -import type { MsgContext } from "../../src/auto-reply/templating.js"; -import { normalizeChatType } from "../../src/channels/chat-type.js"; -import { resolveConversationLabel } from "../../src/channels/conversation-label.js"; -import { validateSenderIdentity } from "../../src/channels/sender-identity.js"; - -export function expectInboundContextContract(ctx: MsgContext) { - expect(validateSenderIdentity(ctx)).toEqual([]); - - expect(ctx.Body).toBeTypeOf("string"); - expect(ctx.BodyForAgent).toBeTypeOf("string"); - expect(ctx.BodyForCommands).toBeTypeOf("string"); - - const chatType = normalizeChatType(ctx.ChatType); - if (chatType && chatType !== "direct") { - const label = ctx.ConversationLabel?.trim() || resolveConversationLabel(ctx); - expect(label).toBeTruthy(); - } -} diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts new file mode 100644 index 00000000000..63561cb5151 --- /dev/null +++ b/test/scripts/test-extension.test.ts @@ -0,0 +1,64 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + detectChangedExtensionIds, + resolveExtensionTestPlan, +} from "../../scripts/test-extension.mjs"; + +const scriptPath = path.join(process.cwd(), "scripts", "test-extension.mjs"); + +function readPlan(args: string[], cwd = process.cwd()) { + const stdout = execFileSync(process.execPath, [scriptPath, ...args, "--dry-run", "--json"], { + cwd, + encoding: "utf8", + }); + return JSON.parse(stdout) as ReturnType; +} + +describe("scripts/test-extension.mjs", () => { + it("resolves channel-root extensions onto the channel vitest config", () => { + const plan = resolveExtensionTestPlan({ targetArg: "slack", cwd: process.cwd() }); + + expect(plan.extensionId).toBe("slack"); + expect(plan.extensionDir).toBe("extensions/slack"); + expect(plan.config).toBe("vitest.channels.config.ts"); + expect(plan.testFiles.some((file) => file.startsWith("extensions/slack/"))).toBe(true); + }); + + it("resolves provider extensions onto the extensions vitest config", () => { + const plan = resolveExtensionTestPlan({ targetArg: "firecrawl", cwd: process.cwd() }); + + expect(plan.extensionId).toBe("firecrawl"); + expect(plan.config).toBe("vitest.extensions.config.ts"); + expect(plan.testFiles.some((file) => file.startsWith("extensions/firecrawl/"))).toBe(true); + }); + + it("includes paired src roots when they contain tests", () => { + const plan = resolveExtensionTestPlan({ targetArg: "line", cwd: process.cwd() }); + + expect(plan.roots).toContain("extensions/line"); + expect(plan.roots).toContain("src/line"); + expect(plan.config).toBe("vitest.channels.config.ts"); + expect(plan.testFiles.some((file) => file.startsWith("src/line/"))).toBe(true); + }); + + it("infers the extension from the current working directory", () => { + const cwd = path.join(process.cwd(), "extensions", "slack"); + const plan = readPlan([], cwd); + + expect(plan.extensionId).toBe("slack"); + expect(plan.extensionDir).toBe("extensions/slack"); + }); + + it("maps changed paths back to extension ids", () => { + const extensionIds = detectChangedExtensionIds([ + "extensions/slack/src/channel.ts", + "src/line/message.test.ts", + "extensions/firecrawl/package.json", + "src/not-a-plugin/file.ts", + ]); + + expect(extensionIds).toEqual(["firecrawl", "line", "slack"]); + }); +}); diff --git a/ui/src/local-storage.ts b/ui/src/local-storage.ts index a1e80d9d32a..e0de8c8cee5 100644 --- a/ui/src/local-storage.ts +++ b/ui/src/local-storage.ts @@ -9,7 +9,7 @@ function isStorage(value: unknown): value is Storage { export function getSafeLocalStorage(): Storage | null { const descriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); - if (process.env.VITEST) { + if (typeof process !== "undefined" && process.env?.VITEST) { return descriptor && !descriptor.get && isStorage(descriptor.value) ? descriptor.value : null; } diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 23f1de68caa..2a9c2685589 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -219,6 +219,9 @@ export async function refreshActiveTab(host: SettingsHost) { if (host.tab === "instances") { await loadPresence(host as unknown as OpenClawApp); } + if (host.tab === "usage") { + await loadUsage(host as unknown as OpenClawApp); + } if (host.tab === "sessions") { await loadSessions(host as unknown as OpenClawApp); }