diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9922ceb12f5..7266469c4a2 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] @@ -162,6 +206,9 @@ jobs: - runtime: node task: channels command: pnpm test:channels + - runtime: node + task: contracts + command: pnpm test:contracts - runtime: node task: protocol command: pnpm protocol:check @@ -205,6 +252,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" @@ -252,6 +322,12 @@ jobs: - name: Build dist run: pnpm build + - name: Smoke test CLI launcher help + run: node openclaw.mjs --help + + - name: Smoke test CLI launcher status json + run: node openclaw.mjs status --json --timeout 1 + - name: Check CLI startup memory run: pnpm test:startup:memory @@ -385,11 +461,20 @@ jobs: run: | set -euo pipefail - if [ "${{ github.event_name }}" = "push" ]; then - BASE="${{ github.event.before }}" - else - BASE="${{ github.event.pull_request.base.sha }}" - fi + BASE="$( + python - <<'PY' + import json + import os + + with open(os.environ["GITHUB_EVENT_PATH"], "r", encoding="utf-8") as fh: + event = json.load(fh) + + if os.environ["GITHUB_EVENT_NAME"] == "push": + print(event["before"]) + else: + print(event["pull_request"]["base"]["sha"]) + PY + )" mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml') if [ "${#workflow_files[@]}" -eq 0 ]; then diff --git a/.gitignore b/.gitignore index 0eabcb6843c..a0da79d14ef 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules docker-compose.override.yml docker-compose.extra.yml dist +dist-runtime pnpm-lock.yaml bun.lock bun.lockb diff --git a/CHANGELOG.md b/CHANGELOG.md index df03ad8fc5d..d948e2b59ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,49 +13,53 @@ 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. +- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root. ### 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 +67,46 @@ 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. +- Control UI/logging: make browser-safe logger imports avoid eager temp-dir resolution so the bundled Control UI no longer crashes to a blank screen when logging reaches `tmp-openclaw-dir`. (#48469) Fixes #48062. Thanks @7inspire. +- 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. +- Plugins/subagents: preserve gateway-owned plugin subagent access across runtime, tool, and embedded-runner load paths so gateway plugin tools and context engines can still spawn and manage subagents after the loader cache split. (#46648) Thanks @jalehman. +- Control UI/overview: keep the language dropdown aligned with the persisted locale during dashboard startup so refreshing the page does not fall back to English before locale hydration completes. (#48019) Thanks @git-jxj. +- Agents/compaction: rerun transcript repair after `session.compact()` so orphaned `tool_result` blocks cannot survive compaction and break later Anthropic requests. (#16095) thanks @claw-sylphx. +- Agents/compaction: trigger overflow recovery from the tool-result guard once post-compaction context still exceeds the safe threshold, so long tool loops compact before the next model call hard-fails. (#29371) thanks @keshav55. ## 2026.3.13 @@ -261,6 +276,10 @@ Docs: https://docs.openclaw.ai - Agents/Anthropic replay: drop replayed assistant thinking blocks for native Anthropic and Bedrock Claude providers so persisted follow-up turns no longer fail on stored thinking blocks. (#44843) Thanks @jmcte. - Docs/Brave pricing: escape literal dollar signs in Brave Search cost text so the docs render the free credit and per-request pricing correctly. (#44989) Thanks @keelanfh. - Feishu/file uploads: preserve literal UTF-8 filenames in `im.file.create` so Chinese and other non-ASCII filenames no longer appear percent-encoded in chat. (#34262) Thanks @fabiaodemianyang and @KangShuaiFu. +- Agents/compaction safeguard: trim large kept `toolResult` payloads consistently for budgeting, pruning, and identifier seeding, then restore preserved payloads after prune so oversized safeguard summaries stay stable. (#44133) thanks @SayrWolfridge. +- Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv. +- Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. +- Agents/Ollama overflow: rewrite Ollama `prompt too long` API payloads through the normal context-overflow sanitizer so embedded sessions keep the friendly overflow copy and auto-compaction trigger. (#34019) thanks @lishuaigit. ## 2026.3.11 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4184a550691..d0327a8ad62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,6 +89,11 @@ 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 ` + - `pnpm test:extension --list` to see valid extension ids + - If you changed shared plugin or channel surfaces, run `pnpm test:contracts` + - If you changed broader runtime behavior, still run the relevant wider 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/README.md b/README.md index fee53d83065..418e2a070af 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,10 @@ It answers you on the channels you already use (WhatsApp, Telegram, Slack, Disco If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. -[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) +[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Onboarding](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) -Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal. -The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. +Preferred setup: run `openclaw onboard` in your terminal. +OpenClaw Onboard guides you step by step through setting up the gateway, workspace, channels, and skills. It is the recommended CLI setup path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. Works with npm, pnpm, or bun. New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started) @@ -58,7 +58,7 @@ npm install -g openclaw@latest openclaw onboard --install-daemon ``` -The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running. +OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so it stays running. ## Quick start (TL;DR) @@ -132,7 +132,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. - **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui). - **[First-class tools](https://docs.openclaw.ai/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions. - **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes). -- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills. +- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — onboarding-driven setup with bundled/managed/workspace skills. ## Star History @@ -143,7 +143,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. ### Core platform - [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui). -- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor). +- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [onboarding](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor). - [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming. - [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/channels/groups). - [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio). @@ -422,7 +422,7 @@ Use these when you’re past the onboarding flow and want the deeper reference. - [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway) - [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web) - [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote) -- [Follow the onboarding wizard flow for a guided setup.](https://docs.openclaw.ai/start/wizard) +- [Follow OpenClaw Onboard for a guided setup.](https://docs.openclaw.ai/start/wizard) - [Wire external triggers via the webhook surface.](https://docs.openclaw.ai/automation/webhook) - [Set up Gmail Pub/Sub triggers.](https://docs.openclaw.ai/automation/gmail-pubsub) - [Learn the macOS menu bar companion details.](https://docs.openclaw.ai/platforms/mac/menu-bar) 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 c63572a5e7f..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", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index f857ff2d7f4..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":5101} +{"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} diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index 9c2f0eb6de4..bf328656ff3 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -126,7 +126,7 @@ launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist ## Onboarding -BlueBubbles is available in the interactive setup wizard: +BlueBubbles is available in interactive onboarding: ``` openclaw onboard diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 41882e78264..ad018aa4d03 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -30,9 +30,9 @@ openclaw plugins install @openclaw/feishu There are two ways to add the Feishu channel: -### Method 1: setup wizard (recommended) +### Method 1: onboarding (recommended) -If you just installed OpenClaw, run the setup wizard: +If you just installed OpenClaw, run onboarding: ```bash openclaw onboard diff --git a/docs/channels/nostr.md b/docs/channels/nostr.md index 46888da0352..c8d5d69753b 100644 --- a/docs/channels/nostr.md +++ b/docs/channels/nostr.md @@ -16,7 +16,7 @@ Nostr is a decentralized protocol for social networking. This channel enables Op ### Onboarding (recommended) -- The setup wizard (`openclaw onboard`) and `openclaw channels add` list optional channel plugins. +- Onboarding (`openclaw onboard`) and `openclaw channels add` list optional channel plugins. - Selecting Nostr prompts you to install the plugin on demand. Install defaults: diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index b5700213830..2758982b8d7 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -115,7 +115,7 @@ Token resolution order is account-aware. In practice, config values win over env `channels.telegram.allowFrom` accepts numeric Telegram user IDs. `telegram:` / `tg:` prefixes are accepted and normalized. `dmPolicy: "allowlist"` with empty `allowFrom` blocks all DMs and is rejected by config validation. - The setup wizard accepts `@username` input and resolves it to numeric IDs. + Onboarding accepts `@username` input and resolves it to numeric IDs. If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token). If you previously relied on pairing-store allowlist files, `openclaw doctor --fix` can recover entries into `channels.telegram.allowFrom` in allowlist flows (for example when `dmPolicy: "allowlist"` has no explicit IDs yet). 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 9c4b58d1c35..8700655c766 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -318,22 +318,22 @@ Initialize config + workspace. Options: - `--workspace `: agent workspace path (default `~/.openclaw/workspace`). -- `--wizard`: run the setup wizard. -- `--non-interactive`: run wizard without prompts. -- `--mode `: wizard mode. +- `--wizard`: run onboarding. +- `--non-interactive`: run onboarding without prompts. +- `--mode `: onboard mode. - `--remote-url `: remote Gateway URL. - `--remote-token `: remote Gateway token. -Wizard auto-runs when any wizard flags are present (`--non-interactive`, `--mode`, `--remote-url`, `--remote-token`). +Onboarding auto-runs when any onboarding flags are present (`--non-interactive`, `--mode`, `--remote-url`, `--remote-token`). ### `onboard` -Interactive wizard to set up gateway, workspace, and skills. +Interactive onboarding for gateway, workspace, and skills. Options: - `--workspace ` -- `--reset` (reset config + credentials + sessions before wizard) +- `--reset` (reset config + credentials + sessions before onboarding) - `--reset-scope ` (default `config+creds+sessions`; use `full` to also remove workspace) - `--non-interactive` - `--mode ` diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 899ccd82713..0b0e9c78beb 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `openclaw onboard` (interactive setup wizard)" +summary: "CLI reference for `openclaw onboard` (interactive onboarding)" read_when: - You want guided setup for gateway, workspace, auth, channels, and skills title: "onboard" @@ -7,11 +7,11 @@ title: "onboard" # `openclaw onboard` -Interactive setup wizard (local or remote Gateway setup). +Interactive onboarding for local or remote Gateway setup. ## Related guides -- CLI onboarding hub: [Setup Wizard (CLI)](/start/wizard) +- CLI onboarding hub: [Onboarding (CLI)](/start/wizard) - Onboarding overview: [Onboarding Overview](/start/onboarding-overview) - CLI onboarding reference: [CLI Setup Reference](/start/wizard-cli-reference) - CLI automation: [CLI Automation](/start/wizard-cli-automation) diff --git a/docs/cli/setup.md b/docs/cli/setup.md index d8992ba8a43..e13cd89e5b2 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -1,7 +1,7 @@ --- summary: "CLI reference for `openclaw setup` (initialize config + workspace)" read_when: - - You’re doing first-run setup without the full setup wizard + - You’re doing first-run setup without full CLI onboarding - You want to set the default workspace path title: "setup" --- @@ -13,7 +13,7 @@ Initialize `~/.openclaw/openclaw.json` and the agent workspace. Related: - Getting started: [Getting started](/start/getting-started) -- Wizard: [Onboarding](/start/onboarding) +- CLI onboarding: [Onboarding (CLI)](/start/wizard) ## Examples @@ -22,7 +22,7 @@ openclaw setup openclaw setup --workspace ~/.openclaw/workspace ``` -To run the wizard via setup: +To run onboarding via setup: ```bash openclaw setup --wizard diff --git a/docs/concepts/models.md b/docs/concepts/models.md index e85e605456f..f3b7797eedb 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -34,9 +34,9 @@ Related: - Use fallbacks for cost/latency-sensitive tasks and lower-stakes chat. - For tool-enabled agents or untrusted inputs, avoid older/weaker model tiers. -## Setup wizard (recommended) +## Onboarding (recommended) -If you don’t want to hand-edit config, run the setup wizard: +If you don’t want to hand-edit config, run onboarding: ```bash openclaw onboard diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index c25501e6cdd..8a7eae00194 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -49,7 +49,7 @@ openclaw models status openclaw doctor ``` -If you’d rather not manage env vars yourself, the setup wizard can store +If you’d rather not manage env vars yourself, onboarding can store API keys for daemon use: `openclaw onboard`. See [Help](/help) for details on env inheritance (`env.shellEnv`, diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index a46f342a360..235d4a18a7b 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 @@ -2942,7 +2950,7 @@ Notes: ## Wizard -Metadata written by CLI wizards (`onboard`, `configure`, `doctor`): +Metadata written by CLI guided setup flows (`onboard`, `configure`, `doctor`): ```json5 { diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index a699e74652f..3ead49f6817 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -38,7 +38,7 @@ See the [full reference](/gateway/configuration-reference) for every available f ```bash - openclaw onboard # full setup wizard + openclaw onboard # full onboarding flow openclaw configure # config wizard ``` 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/gateway/security/index.md b/docs/gateway/security/index.md index 68be08fbed5..7741707a62b 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -738,7 +738,7 @@ In minimal mode, the Gateway still broadcasts enough for device discovery (`role Gateway auth is **required by default**. If no token/password is configured, the Gateway refuses WebSocket connections (fail‑closed). -The setup wizard generates a token by default (even for loopback) so +Onboarding generates a token by default (even for loopback) so local clients must authenticate. Set a token so **all** WS clients must authenticate: diff --git a/docs/help/faq.md b/docs/help/faq.md index b32b1aac8c5..cc52aafd604 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -36,7 +36,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [How do I install OpenClaw on a VPS?](#how-do-i-install-openclaw-on-a-vps) - [Where are the cloud/VPS install guides?](#where-are-the-cloudvps-install-guides) - [Can I ask OpenClaw to update itself?](#can-i-ask-openclaw-to-update-itself) - - [What does the setup wizard actually do?](#what-does-the-setup-wizard-actually-do) + - [What does onboarding actually do?](#what-does-onboarding-actually-do) - [Do I need a Claude or OpenAI subscription to run this?](#do-i-need-a-claude-or-openai-subscription-to-run-this) - [Can I use Claude Max subscription without an API key](#can-i-use-claude-max-subscription-without-an-api-key) - [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setuptoken-auth-work) @@ -317,7 +317,7 @@ Install docs: [Install](/install), [Installer flags](/install/installer), [Updat ### What's the recommended way to install and set up OpenClaw -The repo recommends running from source and using the setup wizard: +The repo recommends running from source and using onboarding: ```bash curl -fsSL https://openclaw.ai/install.sh | bash @@ -627,7 +627,7 @@ More detail: [Install](/install) and [Installer flags](/install/installer). ### How do I install OpenClaw on Linux -Short answer: follow the Linux guide, then run the setup wizard. +Short answer: follow the Linux guide, then run onboarding. - Linux quick path + service install: [Linux](/platforms/linux). - Full walkthrough: [Getting Started](/start/getting-started). @@ -685,7 +685,7 @@ openclaw gateway restart Docs: [Update](/cli/update), [Updating](/install/updating). -### What does the setup wizard actually do +### What does onboarding actually do `openclaw onboard` is the recommended setup path. In **local mode** it walks you through: @@ -723,7 +723,7 @@ If you want the clearest and safest supported path for production, use an Anthro ### How does Anthropic setuptoken auth work -`claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. Choose **Anthropic token (paste setup-token)** in the wizard or paste it with `openclaw models auth paste-token --provider anthropic`. The token is stored as an auth profile for the **anthropic** provider and used like an API key (no auto-refresh). More detail: [OAuth](/concepts/oauth). +`claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. Choose **Anthropic token (paste setup-token)** in onboarding or paste it with `openclaw models auth paste-token --provider anthropic`. The token is stored as an auth profile for the **anthropic** provider and used like an API key (no auto-refresh). More detail: [OAuth](/concepts/oauth). ### Where do I find an Anthropic setuptoken @@ -733,7 +733,7 @@ It is **not** in the Anthropic Console. The setup-token is generated by the **Cl claude setup-token ``` -Copy the token it prints, then choose **Anthropic token (paste setup-token)** in the wizard. If you want to run it on the gateway host, use `openclaw models auth setup-token --provider anthropic`. If you ran `claude setup-token` elsewhere, paste it on the gateway host with `openclaw models auth paste-token --provider anthropic`. See [Anthropic](/providers/anthropic). +Copy the token it prints, then choose **Anthropic token (paste setup-token)** in onboarding. If you want to run it on the gateway host, use `openclaw models auth setup-token --provider anthropic`. If you ran `claude setup-token` elsewhere, paste it on the gateway host with `openclaw models auth paste-token --provider anthropic`. See [Anthropic](/providers/anthropic). ### Do you support Claude subscription auth (Claude Pro or Max) @@ -767,15 +767,15 @@ Yes - via pi-ai's **Amazon Bedrock (Converse)** provider with **manual config**. ### How does Codex auth work -OpenClaw supports **OpenAI Code (Codex)** via OAuth (ChatGPT sign-in). The wizard can run the OAuth flow and will set the default model to `openai-codex/gpt-5.4` when appropriate. See [Model providers](/concepts/model-providers) and [Wizard](/start/wizard). +OpenClaw supports **OpenAI Code (Codex)** via OAuth (ChatGPT sign-in). Onboarding can run the OAuth flow and will set the default model to `openai-codex/gpt-5.4` when appropriate. See [Model providers](/concepts/model-providers) and [Onboarding (CLI)](/start/wizard). ### Do you support OpenAI subscription auth Codex OAuth Yes. OpenClaw fully supports **OpenAI Code (Codex) subscription OAuth**. OpenAI explicitly allows subscription OAuth usage in external tools/workflows -like OpenClaw. The setup wizard can run the OAuth flow for you. +like OpenClaw. Onboarding can run the OAuth flow for you. -See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Wizard](/start/wizard). +See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Onboarding (CLI)](/start/wizard). ### How do I set up Gemini CLI OAuth @@ -844,7 +844,7 @@ without WhatsApp/Telegram. `channels.telegram.allowFrom` is **the human sender's Telegram user ID** (numeric). It is not the bot username. -The setup wizard accepts `@username` input and resolves it to a numeric ID, but OpenClaw authorization uses numeric IDs only. +Onboarding accepts `@username` input and resolves it to a numeric ID, but OpenClaw authorization uses numeric IDs only. Safer (no third-party bot): @@ -1909,7 +1909,7 @@ openclaw onboard --install-daemon Notes: -- The setup wizard also offers **Reset** if it sees an existing config. See [Wizard](/start/wizard). +- Onboarding also offers **Reset** if it sees an existing config. See [Onboarding (CLI)](/start/wizard). - If you used profiles (`--profile` / `OPENCLAW_PROFILE`), reset each state dir (defaults are `~/.openclaw-`). - Dev reset: `openclaw gateway --dev --reset` (dev-only; wipes dev config + credentials + sessions + workspace). diff --git a/docs/index.md b/docs/index.md index 7c69600f55d..25162bc9676 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,7 +33,7 @@ title: "OpenClaw" Install OpenClaw and bring up the Gateway in minutes. - + Guided setup with `openclaw onboard` and pairing flows. diff --git a/docs/install/docker.md b/docs/install/docker.md index a9f6b578bd0..f4913a5138a 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -51,7 +51,7 @@ From repo root: This script: - builds the gateway image locally (or pulls a remote image if `OPENCLAW_IMAGE` is set) -- runs the setup wizard +- runs onboarding - prints optional provider setup hints - starts the gateway via Docker Compose - generates a gateway token and writes it to `.env` diff --git a/docs/install/index.md b/docs/install/index.md index 21adfdaa592..7130cf9faac 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -33,7 +33,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl - Downloads the CLI, installs it globally via npm, and launches the setup wizard. + Downloads the CLI, installs it globally via npm, and launches onboarding. diff --git a/docs/install/northflank.mdx b/docs/install/northflank.mdx index d3157d72e74..03a41d1013b 100644 --- a/docs/install/northflank.mdx +++ b/docs/install/northflank.mdx @@ -21,7 +21,7 @@ and you configure everything via the `/setup` web wizard. ## What you get - Hosted OpenClaw Gateway + Control UI -- Web setup wizard at `/setup` (no terminal commands) +- Web setup at `/setup` (no terminal commands) - Persistent storage via Northflank Volume (`/data`) so config/credentials/workspace survive redeploys ## Setup flow @@ -32,7 +32,7 @@ and you configure everything via the `/setup` web wizard. 4. Click **Run setup**. 5. Open the Control UI at `https:///openclaw` -If Telegram DMs are set to pairing, the setup wizard can approve the pairing code. +If Telegram DMs are set to pairing, web setup can approve the pairing code. ## Getting chat tokens diff --git a/docs/install/railway.mdx b/docs/install/railway.mdx index 73f23fbe48a..1548069b4fd 100644 --- a/docs/install/railway.mdx +++ b/docs/install/railway.mdx @@ -29,13 +29,13 @@ Railway will either: Then open: -- `https:///setup` — setup wizard (password protected) +- `https:///setup` — web setup (password protected) - `https:///openclaw` — Control UI ## What you get - Hosted OpenClaw Gateway + Control UI -- Web setup wizard at `/setup` (no terminal commands) +- Web setup at `/setup` (no terminal commands) - Persistent storage via Railway Volume (`/data`) so config/credentials/workspace survive redeploys - Backup export at `/setup/export` to migrate off Railway later @@ -70,7 +70,7 @@ Set these variables on the service: 3. (Optional) Add Telegram/Discord/Slack tokens. 4. Click **Run setup**. -If Telegram DMs are set to pairing, the setup wizard can approve the pairing code. +If Telegram DMs are set to pairing, web setup can approve the pairing code. ## Getting chat tokens diff --git a/docs/install/render.mdx b/docs/install/render.mdx index ae945687025..7e43bfca012 100644 --- a/docs/install/render.mdx +++ b/docs/install/render.mdx @@ -73,7 +73,7 @@ The Blueprint defaults to `starter`. To use free tier, change `plan: free` in yo ## After deployment -### Complete the setup wizard +### Complete web setup 1. Navigate to `https://.onrender.com/setup` 2. Enter your `SETUP_PASSWORD` diff --git a/docs/install/updating.md b/docs/install/updating.md index a8161cc07f0..dd3128c553e 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -22,7 +22,7 @@ curl -fsSL https://openclaw.ai/install.sh | bash Notes: -- Add `--no-onboard` if you don’t want the setup wizard to run again. +- Add `--no-onboard` if you don’t want onboarding to run again. - For **source installs**, use: ```bash diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md index 2050b6395b4..7b5e22f89c6 100644 --- a/docs/platforms/raspberry-pi.md +++ b/docs/platforms/raspberry-pi.md @@ -321,7 +321,7 @@ Since the Pi is just the Gateway (models run in the cloud), use API-based models ## Auto-Start on Boot -The setup wizard sets this up, but to verify: +Onboarding sets this up, but to verify: ```bash # Check service is enabled diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index 14198fdba36..531b6c48595 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -204,7 +204,7 @@ Example with a stable public host: ## TTS for calls -Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for +Voice Call uses the core `messages.tts` configuration for streaming speech on calls. You can override it under the plugin config with the **same shape** — it deep‑merges with `messages.tts`. @@ -222,7 +222,7 @@ streaming speech on calls. You can override it under the plugin config with the Notes: -- **Edge TTS is ignored for voice calls** (telephony audio needs PCM; Edge output is unreliable). +- **Microsoft speech is ignored for voice calls** (telephony audio needs PCM; the current Microsoft transport does not expose telephony PCM output). - Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices. ### More examples diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index 5a1eb2bd27e..c3ea5aa7d3c 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -16,15 +16,15 @@ Ollama is a local LLM runtime that makes it easy to run open-source models on yo ## Quick start -### Onboarding wizard (recommended) +### Onboarding (recommended) -The fastest way to set up Ollama is through the setup wizard: +The fastest way to set up Ollama is through onboarding: ```bash openclaw onboard ``` -Select **Ollama** from the provider list. The wizard will: +Select **Ollama** from the provider list. Onboarding will: 1. Ask for the Ollama base URL where your instance can be reached (default `http://127.0.0.1:11434`). 2. Let you choose **Cloud + Local** (cloud models and local models) or **Local** (local models only). diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 5bfa3da7f9f..fce13301ea9 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -1,24 +1,24 @@ --- -summary: "Full reference for the CLI setup wizard: every step, flag, and config field" +summary: "Full reference for CLI onboarding: every step, flag, and config field" read_when: - - Looking up a specific wizard step or flag + - Looking up a specific onboarding step or flag - Automating onboarding with non-interactive mode - - Debugging wizard behavior -title: "Setup Wizard Reference" -sidebarTitle: "Wizard Reference" + - Debugging onboarding behavior +title: "Onboarding Reference" +sidebarTitle: "Onboarding Reference" --- -# Setup Wizard Reference +# Onboarding Reference -This is the full reference for the `openclaw onboard` CLI wizard. -For a high-level overview, see [Setup Wizard](/start/wizard). +This is the full reference for `openclaw onboard`. +For a high-level overview, see [Onboarding (CLI)](/start/wizard). ## Flow details (local mode) - If `~/.openclaw/openclaw.json` exists, choose **Keep / Modify / Reset**. - - Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset** + - Re-running onboarding does **not** wipe anything unless you explicitly choose **Reset** (or pass `--reset`). - CLI `--reset` defaults to `config+creds+sessions`; use `--reset-scope full` to also remove workspace. @@ -31,9 +31,9 @@ For a high-level overview, see [Setup Wizard](/start/wizard). - **Anthropic API key**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use. - - **Anthropic OAuth (Claude Code CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present. + - **Anthropic OAuth (Claude Code CLI)**: on macOS onboarding checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present. - **Anthropic token (paste setup-token)**: run `claude setup-token` on any machine, then paste the token (you can name it; blank = default). - - **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it. + - **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, onboarding can reuse it. - **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`. - Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`. - **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles. @@ -55,7 +55,7 @@ For a high-level overview, see [Setup Wizard](/start/wizard). - More detail: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot) - **Skip**: no auth configured yet. - Pick a default model from detected options (or enter provider/model manually). For best quality and lower prompt-injection risk, choose the strongest latest-generation model available in your provider stack. - - Wizard runs a model check and warns if the configured model is unknown or missing auth. + - Onboarding runs a model check and warns if the configured model is unknown or missing auth. - API key storage mode defaults to plaintext auth-profile values. Use `--secret-input-mode ref` to store env-backed refs instead (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`). - OAuth credentials live in `~/.openclaw/credentials/oauth.json`; auth profiles live in `~/.openclaw/agents//agent/auth-profiles.json` (API keys + OAuth). - More detail: [/concepts/oauth](/concepts/oauth) @@ -106,7 +106,7 @@ For a high-level overview, see [Setup Wizard](/start/wizard). - macOS: LaunchAgent - Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped). - Linux (and Windows via WSL2): systemd user unit - - Wizard attempts to enable lingering via `loginctl enable-linger ` so the Gateway stays up after logout. + - Onboarding attempts to enable lingering via `loginctl enable-linger ` so the Gateway stays up after logout. - May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first. - **Runtime selection:** Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**. - If token auth requires a token and `gateway.auth.token` is SecretRef-managed, daemon install validates it but does not persist resolved plaintext token values into supervisor service environment metadata. @@ -128,8 +128,8 @@ For a high-level overview, see [Setup Wizard](/start/wizard). -If no GUI is detected, the wizard prints SSH port-forward instructions for the Control UI instead of opening a browser. -If the Control UI assets are missing, the wizard attempts to build them; fallback is `pnpm ui:build` (auto-installs UI deps). +If no GUI is detected, onboarding prints SSH port-forward instructions for the Control UI instead of opening a browser. +If the Control UI assets are missing, onboarding attempts to build them; fallback is `pnpm ui:build` (auto-installs UI deps). ## Non-interactive mode @@ -183,12 +183,12 @@ openclaw agents add work \ ## Gateway wizard RPC -The Gateway exposes the wizard flow over RPC (`wizard.start`, `wizard.next`, `wizard.cancel`, `wizard.status`). +The Gateway exposes the onboarding flow over RPC (`wizard.start`, `wizard.next`, `wizard.cancel`, `wizard.status`). Clients (macOS app, Control UI) can render steps without re‑implementing onboarding logic. ## Signal setup (signal-cli) -The wizard can install `signal-cli` from GitHub releases: +Onboarding can install `signal-cli` from GitHub releases: - Downloads the appropriate release asset. - Stores it under `~/.openclaw/tools/signal-cli//`. @@ -223,12 +223,12 @@ Typical fields in `~/.openclaw/openclaw.json`: WhatsApp credentials go under `~/.openclaw/credentials/whatsapp//`. Sessions are stored under `~/.openclaw/agents//sessions/`. -Some channels are delivered as plugins. When you pick one during setup, the wizard +Some channels are delivered as plugins. When you pick one during setup, onboarding will prompt to install it (npm or a local path) before it can be configured. ## Related docs -- Wizard overview: [Setup Wizard](/start/wizard) +- Onboarding overview: [Onboarding (CLI)](/start/wizard) - macOS app onboarding: [Onboarding](/start/onboarding) - Config reference: [Gateway configuration](/gateway/configuration) - Providers: [WhatsApp](/channels/whatsapp), [Telegram](/channels/telegram), [Discord](/channels/discord), [Google Chat](/channels/googlechat), [Signal](/channels/signal), [BlueBubbles](/channels/bluebubbles) (iMessage), [iMessage](/channels/imessage) (legacy) diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index 3fc64e5087d..bd3f554cdc4 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -52,13 +52,13 @@ Check your Node version with `node --version` if you are unsure. - + ```bash openclaw onboard --install-daemon ``` - The wizard configures auth, gateway settings, and optional channels. - See [Setup Wizard](/start/wizard) for details. + Onboarding configures auth, gateway settings, and optional channels. + See [Onboarding (CLI)](/start/wizard) for details. @@ -114,8 +114,8 @@ Full environment variable reference: [Environment vars](/help/environment). ## Go deeper - - Full CLI wizard reference and advanced options. + + Full CLI onboarding reference and advanced options. First run flow for the macOS app. diff --git a/docs/start/hubs.md b/docs/start/hubs.md index 9833b467378..882f547f65a 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -19,7 +19,7 @@ Use these hubs to discover every page, including deep dives and reference docs t - [Getting Started](/start/getting-started) - [Quick start](/start/quickstart) - [Onboarding](/start/onboarding) -- [Wizard](/start/wizard) +- [Onboarding (CLI)](/start/wizard) - [Setup](/start/setup) - [Dashboard (local Gateway)](http://127.0.0.1:18789/) - [Help](/help) diff --git a/docs/start/onboarding-overview.md b/docs/start/onboarding-overview.md index 1e94a4db64a..1e60ce9cef5 100644 --- a/docs/start/onboarding-overview.md +++ b/docs/start/onboarding-overview.md @@ -14,21 +14,21 @@ and how you prefer to configure providers. ## Choose your onboarding path -- **CLI wizard** for macOS, Linux, and Windows (via WSL2). +- **CLI onboarding** for macOS, Linux, and Windows (via WSL2). - **macOS app** for a guided first run on Apple silicon or Intel Macs. -## CLI setup wizard +## CLI onboarding -Run the wizard in a terminal: +Run onboarding in a terminal: ```bash openclaw onboard ``` -Use the CLI wizard when you want full control of the Gateway, workspace, +Use CLI onboarding when you want full control of the Gateway, workspace, channels, and skills. Docs: -- [Setup Wizard (CLI)](/start/wizard) +- [Onboarding (CLI)](/start/wizard) - [`openclaw onboard` command](/cli/onboard) ## macOS app onboarding @@ -41,7 +41,7 @@ Use the OpenClaw app when you want a fully guided setup on macOS. Docs: If you need an endpoint that is not listed, including hosted providers that expose standard OpenAI or Anthropic APIs, choose **Custom Provider** in the -CLI wizard. You will be asked to: +CLI onboarding. You will be asked to: - Pick OpenAI-compatible, Anthropic-compatible, or **Unknown** (auto-detect). - Enter a base URL and API key (if required by the provider). diff --git a/docs/start/quickstart.md b/docs/start/quickstart.md index 238af2881e3..f4b96893fe6 100644 --- a/docs/start/quickstart.md +++ b/docs/start/quickstart.md @@ -16,7 +16,7 @@ Quick start is now part of [Getting Started](/start/getting-started). Install OpenClaw and run your first chat in minutes. - - Full CLI wizard reference and advanced options. + + Full CLI onboarding reference and advanced options. diff --git a/docs/start/setup.md b/docs/start/setup.md index bf127cc0ad0..7e3ec6dfc2d 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -10,7 +10,7 @@ title: "Setup" If you are setting up for the first time, start with [Getting Started](/start/getting-started). -For wizard details, see [Onboarding Wizard](/start/wizard). +For onboarding details, see [Onboarding (CLI)](/start/wizard). Last updated: 2026-01-01 diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index 884d49e143b..f373f3d4bc6 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -33,7 +33,7 @@ openclaw onboard --non-interactive \ Add `--json` for a machine-readable summary. Use `--secret-input-mode ref` to store env-backed refs in auth profiles instead of plaintext values. -Interactive selection between env refs and configured provider refs (`file` or `exec`) is available in the setup wizard flow. +Interactive selection between env refs and configured provider refs (`file` or `exec`) is available in the onboarding flow. In non-interactive `ref` mode, provider env vars must be set in the process environment. Passing inline key flags without the matching env var now fails fast. @@ -210,6 +210,6 @@ Notes: ## Related docs -- Onboarding hub: [Setup Wizard (CLI)](/start/wizard) +- Onboarding hub: [Onboarding (CLI)](/start/wizard) - Full reference: [CLI Setup Reference](/start/wizard-cli-reference) - Command reference: [`openclaw onboard`](/cli/onboard) diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 36bd836a13f..a08204c0f20 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -10,7 +10,7 @@ sidebarTitle: "CLI reference" # CLI Setup Reference This page is the full reference for `openclaw onboard`. -For the short guide, see [Setup Wizard (CLI)](/start/wizard). +For the short guide, see [Onboarding (CLI)](/start/wizard). ## What the wizard does @@ -294,6 +294,6 @@ Signal setup behavior: ## Related docs -- Onboarding hub: [Setup Wizard (CLI)](/start/wizard) +- Onboarding hub: [Onboarding (CLI)](/start/wizard) - Automation and scripts: [CLI Automation](/start/wizard-cli-automation) - Command reference: [`openclaw onboard`](/cli/onboard) diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 7bbe9df64cf..3ea6ff55255 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -1,15 +1,15 @@ --- -summary: "CLI setup wizard: guided setup for gateway, workspace, channels, and skills" +summary: "CLI onboarding: guided setup for gateway, workspace, channels, and skills" read_when: - - Running or configuring the setup wizard + - Running or configuring CLI onboarding - Setting up a new machine -title: "Setup Wizard (CLI)" +title: "Onboarding (CLI)" sidebarTitle: "Onboarding: CLI" --- -# Setup Wizard (CLI) +# Onboarding (CLI) -The setup wizard is the **recommended** way to set up OpenClaw on macOS, +CLI onboarding is the **recommended** way to set up OpenClaw on macOS, Linux, or Windows (via WSL2; strongly recommended). It configures a local Gateway or a remote Gateway connection, plus channels, skills, and workspace defaults in one guided flow. @@ -35,7 +35,7 @@ openclaw agents add -The setup wizard includes a web search step where you can pick a provider +CLI onboarding includes a web search step where you can pick a provider (Perplexity, Brave, Gemini, Grok, or Kimi) and paste your API key so the agent can use `web_search`. You can also configure this later with `openclaw configure --section web`. Docs: [Web tools](/tools/web). @@ -43,7 +43,7 @@ can use `web_search`. You can also configure this later with ## QuickStart vs Advanced -The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). +Onboarding starts with **QuickStart** (defaults) vs **Advanced** (full control). @@ -61,7 +61,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). -## What the wizard configures +## What onboarding configures **Local mode (default)** walks you through these steps: @@ -84,9 +84,9 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). 7. **Skills** — Installs recommended skills and optional dependencies. -Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset** (or pass `--reset`). +Re-running onboarding does **not** wipe anything unless you explicitly choose **Reset** (or pass `--reset`). CLI `--reset` defaults to config, credentials, and sessions; use `--reset-scope full` to include workspace. -If the config is invalid or contains legacy keys, the wizard asks you to run `openclaw doctor` first. +If the config is invalid or contains legacy keys, onboarding asks you to run `openclaw doctor` first. **Remote mode** only configures the local client to connect to a Gateway elsewhere. @@ -95,7 +95,7 @@ It does **not** install or change anything on the remote host. ## Add another agent Use `openclaw agents add ` to create a separate agent with its own workspace, -sessions, and auth profiles. Running without `--workspace` launches the wizard. +sessions, and auth profiles. Running without `--workspace` launches onboarding. What it sets: @@ -106,7 +106,7 @@ What it sets: Notes: - Default workspaces follow `~/.openclaw/workspace-`. -- Add `bindings` to route inbound messages (the wizard can do this). +- Add `bindings` to route inbound messages (onboarding can do this). - Non-interactive flags: `--model`, `--agent-dir`, `--bind`, `--non-interactive`. ## Full reference @@ -115,7 +115,7 @@ For detailed step-by-step breakdowns and config outputs, see [CLI Setup Reference](/start/wizard-cli-reference). For non-interactive examples, see [CLI Automation](/start/wizard-cli-automation). For the deeper technical reference, including RPC details, see -[Wizard Reference](/reference/wizard). +[Onboarding Reference](/reference/wizard). ## Related docs 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 770eaa215e0..3e53c5e205e 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -97,6 +97,76 @@ The important design boundary: That split lets OpenClaw validate config, explain missing/disabled plugins, and build UI/schema hints before the full runtime is active. +## Capability ownership model + +OpenClaw treats a native plugin as the ownership boundary for a **company** or a +**feature**, not as a grab bag of unrelated integrations. + +That means: + +- a company plugin should usually own all of that company's OpenClaw-facing + surfaces +- a feature plugin should usually own the full feature surface it introduces +- channels should consume shared core capabilities instead of re-implementing + provider behavior ad hoc + +Examples: + +- the bundled `openai` plugin owns OpenAI model-provider behavior and OpenAI + speech behavior +- the bundled `elevenlabs` plugin owns ElevenLabs speech behavior +- the bundled `microsoft` plugin owns Microsoft speech behavior +- the `voice-call` plugin is a feature plugin: it owns call transport, tools, + CLI, routes, and runtime, but it consumes core TTS/STT capability instead of + inventing a second speech stack + +The intended end state is: + +- OpenAI lives in one plugin even if it spans text models, speech, images, and + future video +- another vendor can do the same for its own surface area +- channels do not care which vendor plugin owns the provider; they consume the + shared capability contract exposed by core + +This is the key distinction: + +- **plugin** = ownership boundary +- **capability** = core contract that multiple plugins can implement or consume + +So if OpenClaw adds a new domain such as video, the first question is not +"which provider should hardcode video handling?" The first question is "what is +the core video capability contract?" Once that contract exists, vendor plugins +can register against it and channel/feature plugins can consume it. + +If the capability does not exist yet, the right move is usually: + +1. define the missing capability in core +2. expose it through the plugin API/runtime in a typed way +3. wire channels/features against that capability +4. let vendor plugins register implementations + +This keeps ownership explicit while avoiding core behavior that depends on a +single vendor or a one-off plugin-specific code path. + +### Capability layering + +Use this mental model when deciding where code belongs: + +- **core capability layer**: shared orchestration, policy, fallback, config + merge rules, delivery semantics, and typed contracts +- **vendor plugin layer**: vendor-specific APIs, auth, model catalogs, speech + synthesis, image generation, future video backends, usage endpoints +- **channel/feature plugin layer**: Slack/Discord/voice-call/etc. integration + that consumes core capabilities and presents them on a surface + +For example, TTS follows this shape: + +- core owns reply-time TTS policy, fallback order, prefs, and channel delivery +- `openai`, `elevenlabs`, and `microsoft` own synthesis implementations +- `voice-call` consumes the telephony TTS runtime helper + +That same pattern should be preferred for future capabilities. + ## Compatible bundles OpenClaw also recognizes two compatible external bundle layouts: @@ -193,6 +263,8 @@ Important trust note: - Model Studio provider catalog — bundled as `modelstudio` (enabled by default) - Moonshot provider runtime — bundled as `moonshot` (enabled by default) - NVIDIA provider catalog — bundled as `nvidia` (enabled by default) +- ElevenLabs speech provider — bundled as `elevenlabs` (enabled by default) +- Microsoft speech provider — bundled as `microsoft` (enabled by default; legacy `edge` input maps here) - OpenAI provider runtime — bundled as `openai` (enabled by default; owns both `openai` and `openai-codex`) - OpenCode Go provider capabilities — bundled as `opencode-go` (enabled by default) - OpenCode Zen provider capabilities — bundled as `opencode` (enabled by default) @@ -218,6 +290,8 @@ Native OpenClaw plugins can register: - Gateway HTTP routes - Agent tools - CLI commands +- Speech providers +- Web search providers - Background services - Context engines - Provider auth flows and model catalogs @@ -229,6 +303,62 @@ Native OpenClaw plugins can register: Native OpenClaw plugins run **in‑process** with the Gateway, so treat them as trusted code. Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). +Think of these registrations as **capability claims**. A plugin is not supposed +to reach into random internals and "just make it work." It should register +against explicit surfaces that OpenClaw understands, validates, and can expose +consistently across config, onboarding, status, docs, and runtime behavior. + +## Contracts and enforcement + +The plugin API surface is intentionally typed and centralized in +`OpenClawPluginApi`. That contract defines the supported registration points and +the runtime helpers a plugin may rely on. + +Why this matters: + +- plugin authors get one stable internal standard +- core can reject duplicate ownership such as two plugins registering the same + provider id +- startup can surface actionable diagnostics for malformed registration +- contract tests can enforce bundled-plugin ownership and prevent silent drift + +There are two layers of enforcement: + +1. **runtime registration enforcement** + The plugin registry validates registrations as plugins load. Examples: + duplicate provider ids, duplicate speech provider ids, and malformed + registrations produce plugin diagnostics instead of undefined behavior. +2. **contract tests** + Bundled plugins are captured in contract registries during test runs so + OpenClaw can assert ownership explicitly. Today this is used for model + providers, web search providers, and bundled registration ownership. + +The practical effect is that OpenClaw knows, up front, which plugin owns which +surface. That lets core and channels compose seamlessly because ownership is +declared, typed, and testable rather than implicit. + +### What belongs in a contract + +Good plugin contracts are: + +- typed +- small +- capability-specific +- owned by core +- reusable by multiple plugins +- consumable by channels/features without vendor knowledge + +Bad plugin contracts are: + +- vendor-specific policy hidden in core +- one-off plugin escape hatches that bypass the registry +- channel code reaching straight into a vendor implementation +- ad hoc runtime objects that are not part of `OpenClawPluginApi` or + `api.runtime` + +When in doubt, raise the abstraction level: define the capability first, then +let plugins plug into it. + ## Provider runtime hooks Provider plugins now have two layers: @@ -530,9 +660,36 @@ const result = await api.runtime.tts.textToSpeechTelephony({ Notes: -- Uses core `messages.tts` configuration (OpenAI or ElevenLabs). +- Uses core `messages.tts` configuration and provider selection. - Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers. -- Edge TTS is not supported for telephony. +- OpenAI and ElevenLabs support telephony today. Microsoft does not. + +Plugins can also register speech providers via `api.registerSpeechProvider(...)`. + +```ts +api.registerSpeechProvider({ + id: "acme-speech", + label: "Acme Speech", + isConfigured: ({ config }) => Boolean(config.messages?.tts), + synthesize: async (req) => { + return { + audioBuffer: Buffer.from([]), + outputFormat: "mp3", + fileExtension: ".mp3", + voiceCompatible: false, + }; + }, +}); +``` + +Notes: + +- Keep TTS policy, fallback, and reply delivery in core. +- Use speech providers for vendor-owned synthesis behavior. +- Legacy Microsoft `edge` input is normalized to the `microsoft` provider id. +- The preferred ownership model is company-oriented: one vendor plugin can own + text, speech, image, and future media providers as OpenClaw adds those + capability contracts. For STT/transcription, plugins can call: @@ -842,6 +999,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 @@ -1079,12 +1267,49 @@ Plugins export either: - `on(...)` for typed lifecycle hooks - `registerChannel` - `registerProvider` +- `registerSpeechProvider` +- `registerWebSearchProvider` - `registerHttpRoute` - `registerCommand` - `registerCli` - `registerContextEngine` - `registerService` +In practice, `register(api)` is also where a plugin declares **ownership**. +That ownership should map cleanly to either: + +- a vendor surface such as OpenAI, ElevenLabs, or Microsoft +- a feature surface such as Voice Call + +Avoid splitting one vendor's capabilities across unrelated plugins unless there +is a strong product reason to do so. The default should be one plugin per +vendor/feature, with core capability contracts separating shared orchestration +from vendor-specific behavior. + +## Adding a new capability + +When a plugin needs behavior that does not fit the current API, do not bypass +the plugin system with a private reach-in. Add the missing capability. + +Recommended sequence: + +1. define the core contract + Decide what shared behavior core should own: policy, fallback, config merge, + lifecycle, channel-facing semantics, and runtime helper shape. +2. add typed plugin registration/runtime surfaces + Extend `OpenClawPluginApi` and/or `api.runtime` with the smallest useful + typed seam. +3. wire core + channel/feature consumers + Channels and feature plugins should consume the new capability through core, + not by importing a vendor implementation directly. +4. register vendor implementations + Vendor plugins then register their backends against the capability. +5. add contract coverage + Add tests so ownership and registration shape stay explicit over time. + +This is how OpenClaw stays opinionated without becoming hardcoded to one +provider's worldview. + Context engine plugins can also register a runtime-owned context manager: ```ts @@ -1752,6 +1977,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/docs/tts.md b/docs/tts.md index 682bbfbd53a..4fe0da77e0a 100644 --- a/docs/tts.md +++ b/docs/tts.md @@ -9,26 +9,27 @@ title: "Text-to-Speech" # Text-to-speech (TTS) -OpenClaw can convert outbound replies into audio using ElevenLabs, OpenAI, or Edge TTS. +OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, or OpenAI. It works anywhere OpenClaw can send audio; Telegram gets a round voice-note bubble. ## Supported services - **ElevenLabs** (primary or fallback provider) +- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`, default when no API keys) - **OpenAI** (primary or fallback provider; also used for summaries) -- **Edge TTS** (primary or fallback provider; uses `node-edge-tts`, default when no API keys) -### Edge TTS notes +### Microsoft speech notes -Edge TTS uses Microsoft Edge's online neural TTS service via the `node-edge-tts` -library. It's a hosted service (not local), uses Microsoft’s endpoints, and does -not require an API key. `node-edge-tts` exposes speech configuration options and -output formats, but not all options are supported by the Edge service. citeturn2search0 +The bundled Microsoft speech provider currently uses Microsoft Edge's online +neural TTS service via the `node-edge-tts` library. It's a hosted service (not +local), uses Microsoft endpoints, and does not require an API key. +`node-edge-tts` exposes speech configuration options and output formats, but +not all options are supported by the service. Legacy config and directive input +using `edge` still works and is normalized to `microsoft`. -Because Edge TTS is a public web service without a published SLA or quota, treat it -as best-effort. If you need guaranteed limits and support, use OpenAI or ElevenLabs. -Microsoft's Speech REST API documents a 10‑minute audio limit per request; Edge TTS -does not publish limits, so assume similar or lower limits. citeturn0search3 +Because this path is a public web service without a published SLA or quota, +treat it as best-effort. If you need guaranteed limits and support, use OpenAI +or ElevenLabs. ## Optional keys @@ -37,8 +38,9 @@ If you want OpenAI or ElevenLabs: - `ELEVENLABS_API_KEY` (or `XI_API_KEY`) - `OPENAI_API_KEY` -Edge TTS does **not** require an API key. If no API keys are found, OpenClaw defaults -to Edge TTS (unless disabled via `messages.tts.edge.enabled=false`). +Microsoft speech does **not** require an API key. If no API keys are found, +OpenClaw defaults to Microsoft (unless disabled via +`messages.tts.microsoft.enabled=false` or `messages.tts.edge.enabled=false`). If multiple providers are configured, the selected provider is used first and the others are fallback options. Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`), @@ -58,7 +60,7 @@ so that provider must also be authenticated if you enable summaries. No. Auto‑TTS is **off** by default. Enable it in config with `messages.tts.auto` or per session with `/tts always` (alias: `/tts on`). -Edge TTS **is** enabled by default once TTS is on, and is used automatically +Microsoft speech **is** enabled by default once TTS is on, and is used automatically when no OpenAI or ElevenLabs API keys are available. ## Config @@ -118,15 +120,15 @@ Full schema is in [Gateway configuration](/gateway/configuration). } ``` -### Edge TTS primary (no API key) +### Microsoft primary (no API key) ```json5 { messages: { tts: { auto: "always", - provider: "edge", - edge: { + provider: "microsoft", + microsoft: { enabled: true, voice: "en-US-MichelleNeural", lang: "en-US", @@ -139,13 +141,13 @@ Full schema is in [Gateway configuration](/gateway/configuration). } ``` -### Disable Edge TTS +### Disable Microsoft speech ```json5 { messages: { tts: { - edge: { + microsoft: { enabled: false, }, }, @@ -205,9 +207,10 @@ Then run: - `tagged` only sends audio when the reply includes `[[tts]]` tags. - `enabled`: legacy toggle (doctor migrates this to `auto`). - `mode`: `"final"` (default) or `"all"` (includes tool/block replies). -- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic). +- `provider`: speech provider id such as `"elevenlabs"`, `"microsoft"`, or `"openai"` (fallback is automatic). - If `provider` is **unset**, OpenClaw prefers `openai` (if key), then `elevenlabs` (if key), - otherwise `edge`. + otherwise `microsoft`. +- Legacy `provider: "edge"` still works and is normalized to `microsoft`. - `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`. - Accepts `provider/model` or a configured model alias. - `modelOverrides`: allow the model to emit TTS directives (on by default). @@ -227,15 +230,16 @@ Then run: - `elevenlabs.applyTextNormalization`: `auto|on|off` - `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`) - `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism) -- `edge.enabled`: allow Edge TTS usage (default `true`; no API key). -- `edge.voice`: Edge neural voice name (e.g. `en-US-MichelleNeural`). -- `edge.lang`: language code (e.g. `en-US`). -- `edge.outputFormat`: Edge output format (e.g. `audio-24khz-48kbitrate-mono-mp3`). - - See Microsoft Speech output formats for valid values; not all formats are supported by Edge. -- `edge.rate` / `edge.pitch` / `edge.volume`: percent strings (e.g. `+10%`, `-5%`). -- `edge.saveSubtitles`: write JSON subtitles alongside the audio file. -- `edge.proxy`: proxy URL for Edge TTS requests. -- `edge.timeoutMs`: request timeout override (ms). +- `microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key). +- `microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`). +- `microsoft.lang`: language code (e.g. `en-US`). +- `microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`). + - See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport. +- `microsoft.rate` / `microsoft.pitch` / `microsoft.volume`: percent strings (e.g. `+10%`, `-5%`). +- `microsoft.saveSubtitles`: write JSON subtitles alongside the audio file. +- `microsoft.proxy`: proxy URL for Microsoft speech requests. +- `microsoft.timeoutMs`: request timeout override (ms). +- `edge.*`: legacy alias for the same Microsoft settings. ## Model-driven overrides (default on) @@ -260,7 +264,7 @@ Here you go. Available directive keys (when enabled): -- `provider` (`openai` | `elevenlabs` | `edge`, requires `allowProvider: true`) +- `provider` (registered speech provider id, for example `openai`, `elevenlabs`, or `microsoft`; requires `allowProvider: true`) - `voice` (OpenAI voice) or `voiceId` (ElevenLabs) - `model` (OpenAI TTS model or ElevenLabs model id) - `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost` @@ -319,13 +323,12 @@ These override `messages.tts.*` for that host. - 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble. - **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI). - 44.1kHz / 128kbps is the default balance for speech clarity. -- **Edge TTS**: uses `edge.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`). - - `node-edge-tts` accepts an `outputFormat`, but not all formats are available - from the Edge service. citeturn2search0 - - Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus). citeturn1search0 +- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`). + - The bundled transport accepts an `outputFormat`, but not all formats are available from the service. + - Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus). - Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need guaranteed Opus voice notes. citeturn1search1 - - If the configured Edge output format fails, OpenClaw retries with MP3. + - If the configured Microsoft output format fails, OpenClaw retries with MP3. OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 204d68605d2..d35b245d814 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -28,7 +28,7 @@ Auth is supplied during the WebSocket handshake via: - `connect.params.auth.token` - `connect.params.auth.password` The dashboard settings panel keeps a token for the current browser tab session and selected gateway URL; passwords are not persisted. - The setup wizard generates a gateway token by default, so paste it here on first connect. + Onboarding generates a gateway token by default, so paste it here on first connect. ## Device pairing (first connection) diff --git a/docs/zh-CN/cli/index.md b/docs/zh-CN/cli/index.md index 46be3ef8ab1..0533d75d6c6 100644 --- a/docs/zh-CN/cli/index.md +++ b/docs/zh-CN/cli/index.md @@ -324,22 +324,22 @@ openclaw [--dev] [--profile ] 选项: - `--workspace `:智能体工作区路径(默认 `~/.openclaw/workspace`)。 -- `--wizard`:运行设置向导。 -- `--non-interactive`:无提示运行向导。 -- `--mode `:向导模式。 +- `--wizard`:运行新手引导。 +- `--non-interactive`:无提示运行新手引导。 +- `--mode `:新手引导模式。 - `--remote-url `:远程 Gateway 网关 URL。 - `--remote-token `:远程 Gateway 网关 token。 -只要存在任意向导标志(`--non-interactive`, `--mode`, `--remote-url`, `--remote-token`),就会自动运行向导。 +只要存在任意新手引导标志(`--non-interactive`, `--mode`, `--remote-url`, `--remote-token`),就会自动运行新手引导。 ### `onboard` -用于设置 gateway、工作区和 Skills 的交互式向导。 +用于设置 gateway、工作区和 Skills 的交互式新手引导。 选项: - `--workspace ` -- `--reset`(在运行向导前重置配置 + 凭据 + 会话) +- `--reset`(在运行新手引导前重置配置 + 凭据 + 会话) - `--reset-scope `(默认 `config+creds+sessions`;使用 `full` 还会删除工作区) - `--non-interactive` - `--mode ` diff --git a/docs/zh-CN/cli/onboard.md b/docs/zh-CN/cli/onboard.md index 66588b9d795..1cee84571c9 100644 --- a/docs/zh-CN/cli/onboard.md +++ b/docs/zh-CN/cli/onboard.md @@ -1,7 +1,7 @@ --- read_when: - 你想通过引导式设置来配置 Gateway 网关、工作区、身份验证、渠道和 Skills -summary: "`openclaw onboard` 的 CLI 参考(交互式设置向导)" +summary: "`openclaw onboard` 的 CLI 参考(交互式新手引导)" title: onboard x-i18n: generated_at: "2026-03-16T06:21:32Z" @@ -14,11 +14,11 @@ x-i18n: # `openclaw onboard` -交互式设置向导(本地或远程 Gateway 网关设置)。 +交互式新手引导(本地或远程 Gateway 网关设置)。 ## 相关指南 -- CLI 新手引导中心:[设置向导(CLI)](/start/wizard) +- CLI 新手引导中心:[CLI 新手引导](/start/wizard) - 新手引导概览:[新手引导概览](/start/onboarding-overview) - CLI 新手引导参考:[CLI 设置参考](/start/wizard-cli-reference) - CLI 自动化:[CLI 自动化](/start/wizard-cli-automation) diff --git a/docs/zh-CN/cli/setup.md b/docs/zh-CN/cli/setup.md index 18936b3bd24..6aa0fe99c3a 100644 --- a/docs/zh-CN/cli/setup.md +++ b/docs/zh-CN/cli/setup.md @@ -1,6 +1,6 @@ --- read_when: - - 你正在进行首次运行设置,但不使用完整的设置向导 + - 你正在进行首次运行设置,但不使用完整的 CLI 新手引导 - 你想设置默认工作区路径 summary: "`openclaw setup` 的 CLI 参考(初始化配置 + 工作区)" title: setup @@ -20,7 +20,7 @@ x-i18n: 相关内容: - 入门指南:[入门指南](/start/getting-started) -- 向导:[新手引导](/start/onboarding) +- CLI 新手引导:[CLI 新手引导](/start/wizard) ## 示例 @@ -29,7 +29,7 @@ openclaw setup openclaw setup --workspace ~/.openclaw/workspace ``` -通过 setup 运行向导: +通过 setup 运行新手引导: ```bash openclaw setup --wizard diff --git a/docs/zh-CN/help/faq.md b/docs/zh-CN/help/faq.md index 8543ea01f22..18b936e2cc8 100644 --- a/docs/zh-CN/help/faq.md +++ b/docs/zh-CN/help/faq.md @@ -39,7 +39,7 @@ x-i18n: - [如何在 VPS 上安装 OpenClaw?](#how-do-i-install-openclaw-on-a-vps) - [云/VPS 安装指南在哪里?](#where-are-the-cloudvps-install-guides) - [可以让 OpenClaw 自行更新吗?](#can-i-ask-openclaw-to-update-itself) - - [新手引导向导具体做了什么?](#what-does-the-onboarding-wizard-actually-do) + - [新手引导具体做了什么?](#新手引导具体做了什么) - [运行 OpenClaw 需要 Claude 或 OpenAI 订阅吗?](#do-i-need-a-claude-or-openai-subscription-to-run-this) - [能否使用 Claude Max 订阅而不需要 API 密钥?](#can-i-use-claude-max-subscription-without-an-api-key) - [Anthropic "setup-token" 认证如何工作?](#how-does-anthropic-setuptoken-auth-work) @@ -310,14 +310,14 @@ openclaw doctor ### 安装和设置 OpenClaw 的推荐方式是什么 -仓库推荐从源码运行并使用新手引导向导: +仓库推荐从源码运行并使用新手引导: ```bash curl -fsSL https://openclaw.ai/install.sh | bash openclaw onboard --install-daemon ``` -向导还可以自动构建 UI 资源。新手引导后,通常在端口 **18789** 上运行 Gateway 网关。 +新手引导还可以自动构建 UI 资源。新手引导后,通常在端口 **18789** 上运行 Gateway 网关。 从源码安装(贡献者/开发者): @@ -334,7 +334,7 @@ openclaw onboard ### 新手引导后如何打开仪表板 -向导现在会在新手引导完成后立即使用带令牌的仪表板 URL 打开浏览器,并在摘要中打印完整链接(带令牌)。保持该标签页打开;如果没有自动启动,请在同一台机器上复制/粘贴打印的 URL。令牌保持在本地主机上——不会从浏览器获取任何内容。 +新手引导现在会在完成后立即使用带令牌的仪表板 URL 打开浏览器,并在摘要中打印完整链接(带令牌)。保持该标签页打开;如果没有自动启动,请在同一台机器上复制/粘贴打印的 URL。令牌保持在本地主机上,不会从浏览器获取任何内容。 ### 如何在本地和远程环境中验证仪表板令牌 @@ -562,7 +562,7 @@ curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method git ### 如何在 Linux 上安装 OpenClaw -简短回答:按照 Linux 指南操作,然后运行新手引导向导。 +简短回答:按照 Linux 指南操作,然后运行新手引导。 - Linux 快速路径 + 服务安装:[Linux](/platforms/linux)。 - 完整指南:[入门](/start/getting-started)。 @@ -614,7 +614,7 @@ openclaw gateway restart 文档:[更新](/cli/update)、[更新指南](/install/updating)。 -### 新手引导向导具体做了什么 +### 新手引导具体做了什么 `openclaw onboard` 是推荐的设置路径。在**本地模式**下,它引导你完成: @@ -642,7 +642,7 @@ Claude Pro/Max 订阅**不包含 API 密钥**,因此这是订阅账户的正 ### Anthropic setup-token 认证如何工作 -`claude setup-token` 通过 Claude Code CLI 生成一个**令牌字符串**(在 Web 控制台中不可用)。你可以在**任何机器**上运行它。在向导中选择 **Anthropic token (paste setup-token)** 或使用 `openclaw models auth paste-token --provider anthropic` 粘贴。令牌作为 **anthropic** 提供商的认证配置文件存储,像 API 密钥一样使用(无自动刷新)。更多详情:[OAuth](/concepts/oauth)。 +`claude setup-token` 通过 Claude Code CLI 生成一个**令牌字符串**(在 Web 控制台中不可用)。你可以在**任何机器**上运行它。在新手引导中选择 **Anthropic token (paste setup-token)** 或使用 `openclaw models auth paste-token --provider anthropic` 粘贴。令牌作为 **anthropic** 提供商的认证配置文件存储,像 API 密钥一样使用(无自动刷新)。更多详情:[OAuth](/concepts/oauth)。 ### 在哪里获取 Anthropic setup-token @@ -652,7 +652,7 @@ Claude Pro/Max 订阅**不包含 API 密钥**,因此这是订阅账户的正 claude setup-token ``` -复制它打印的令牌,然后在向导中选择 **Anthropic token (paste setup-token)**。如果你想在 Gateway 网关主机上运行,使用 `openclaw models auth setup-token --provider anthropic`。如果你在其他地方运行了 `claude setup-token`,在 Gateway 网关主机上使用 `openclaw models auth paste-token --provider anthropic` 粘贴。参阅 [Anthropic](/providers/anthropic)。 +复制它打印的令牌,然后在新手引导中选择 **Anthropic token (paste setup-token)**。如果你想在 Gateway 网关主机上运行,使用 `openclaw models auth setup-token --provider anthropic`。如果你在其他地方运行了 `claude setup-token`,在 Gateway 网关主机上使用 `openclaw models auth paste-token --provider anthropic` 粘贴。参阅 [Anthropic](/providers/anthropic)。 ### 是否支持 Claude 订阅认证(Claude Pro/Max) @@ -673,13 +673,13 @@ claude setup-token ### Codex 认证如何工作 -OpenClaw 通过 OAuth(ChatGPT 登录)支持 **OpenAI Code (Codex)**。向导可以运行 OAuth 流程,并在适当时将默认模型设置为 `openai-codex/gpt-5.2`。参阅[模型提供商](/concepts/model-providers)和[向导](/start/wizard)。 +OpenClaw 通过 OAuth(ChatGPT 登录)支持 **OpenAI Code (Codex)**。新手引导可以运行 OAuth 流程,并在适当时将默认模型设置为 `openai-codex/gpt-5.2`。参阅[模型提供商](/concepts/model-providers)和[CLI 新手引导](/start/wizard)。 ### 是否支持 OpenAI 订阅认证(Codex OAuth) -是的。OpenClaw 完全支持 **OpenAI Code (Codex) 订阅 OAuth**。新手引导向导可以为你运行 OAuth 流程。 +是的。OpenClaw 完全支持 **OpenAI Code (Codex) 订阅 OAuth**。新手引导可以为你运行 OAuth 流程。 -参阅 [OAuth](/concepts/oauth)、[模型提供商](/concepts/model-providers)和[向导](/start/wizard)。 +参阅 [OAuth](/concepts/oauth)、[模型提供商](/concepts/model-providers)和[CLI 新手引导](/start/wizard)。 ### 如何设置 Gemini CLI OAuth @@ -1632,7 +1632,7 @@ openclaw onboard --install-daemon 注意: -- 新手引导向导在看到现有配置时也提供**重置**选项。参阅[向导](/start/wizard)。 +- 新手引导在看到现有配置时也提供**重置**选项。参阅[CLI 新手引导](/start/wizard)。 - 如果你使用了配置文件(`--profile` / `OPENCLAW_PROFILE`),重置每个状态目录(默认为 `~/.openclaw-`)。 - 开发重置:`openclaw gateway --dev --reset`(仅限开发;清除开发配置 + 凭据 + 会话 + 工作区)。 diff --git a/docs/zh-CN/index.md b/docs/zh-CN/index.md index 3999dc6fda4..1444fd2f3da 100644 --- a/docs/zh-CN/index.md +++ b/docs/zh-CN/index.md @@ -40,7 +40,7 @@ x-i18n: 安装 OpenClaw 并在几分钟内启动 Gateway 网关。 - + 通过 `openclaw onboard` 和配对流程进行引导式设置。 diff --git a/docs/zh-CN/start/getting-started.md b/docs/zh-CN/start/getting-started.md index 39e3fb3829f..0707dd7b1d0 100644 --- a/docs/zh-CN/start/getting-started.md +++ b/docs/zh-CN/start/getting-started.md @@ -60,13 +60,13 @@ x-i18n: - + ```bash openclaw onboard --install-daemon ``` - 向导会配置认证、Gateway 网关设置和可选渠道。 - 详情请参见 [Setup Wizard](/start/wizard)。 + 新手引导会配置认证、Gateway 网关设置和可选渠道。 + 详情请参见 [CLI 新手引导](/start/wizard)。 @@ -122,8 +122,8 @@ x-i18n: ## 深入了解 - - 完整的 CLI 向导参考和高级选项。 + + 完整的 CLI 新手引导参考和高级选项。 macOS 应用的首次运行流程。 diff --git a/docs/zh-CN/start/hubs.md b/docs/zh-CN/start/hubs.md index b303102dcc0..c5dce882420 100644 --- a/docs/zh-CN/start/hubs.md +++ b/docs/zh-CN/start/hubs.md @@ -26,7 +26,7 @@ x-i18n: - [入门指南](/start/getting-started) - [快速开始](/start/quickstart) - [新手引导](/start/onboarding) -- [向导](/start/wizard) +- [CLI 新手引导](/start/wizard) - [安装配置](/start/setup) - [仪表盘(本地 Gateway 网关)](http://127.0.0.1:18789/) - [帮助](/help) diff --git a/docs/zh-CN/start/onboarding-overview.md b/docs/zh-CN/start/onboarding-overview.md index 524bd8b33f5..ed301f41f5f 100644 --- a/docs/zh-CN/start/onboarding-overview.md +++ b/docs/zh-CN/start/onboarding-overview.md @@ -21,21 +21,21 @@ OpenClaw 支持多种新手引导路径,具体取决于 Gateway 网关运行 ## 选择你的新手引导路径 -- 适用于 macOS、Linux 和 Windows(通过 WSL2)的 **CLI 向导**。 +- 适用于 macOS、Linux 和 Windows(通过 WSL2)的 **CLI 新手引导**。 - 适用于 Apple silicon 或 Intel Mac 的 **macOS 应用**,提供引导式首次运行体验。 -## CLI 设置向导 +## CLI 新手引导 -在终端中运行向导: +在终端中运行新手引导: ```bash openclaw onboard ``` 当你希望完全控制 Gateway 网关、工作区、 -渠道和 Skills 时,请使用 CLI 向导。文档: +渠道和 Skills 时,请使用 CLI 新手引导。文档: -- [设置向导(CLI)](/start/wizard) +- [CLI 新手引导](/start/wizard) - [`openclaw onboard` 命令](/cli/onboard) ## macOS 应用新手引导 @@ -48,7 +48,7 @@ openclaw onboard 如果你需要一个未列出的端点,包括那些 公开标准 OpenAI 或 Anthropic API 的托管提供商,请在 -CLI 向导中选择 **Custom Provider**。系统会要求你: +在 CLI 新手引导中选择 **Custom Provider**。系统会要求你: - 选择兼容 OpenAI、兼容 Anthropic,或 **Unknown**(自动检测)。 - 输入基础 URL 和 API 密钥(如果提供商需要)。 diff --git a/docs/zh-CN/start/wizard.md b/docs/zh-CN/start/wizard.md index 0be36f3cdfb..b168e580b62 100644 --- a/docs/zh-CN/start/wizard.md +++ b/docs/zh-CN/start/wizard.md @@ -1,10 +1,10 @@ --- read_when: - - 运行或配置设置向导 + - 运行或配置 CLI 新手引导 - 设置一台新机器 sidebarTitle: "Onboarding: CLI" -summary: CLI 设置向导:用于 Gateway 网关、工作区、渠道和 Skills 的引导式设置 -title: 设置向导(CLI) +summary: CLI 新手引导:用于 Gateway 网关、工作区、渠道和 Skills 的引导式设置 +title: CLI 新手引导 x-i18n: generated_at: "2026-03-16T06:28:38Z" model: gpt-5.4 @@ -14,9 +14,9 @@ x-i18n: workflow: 15 --- -# 设置向导(CLI) +# CLI 新手引导 -设置向导是在 macOS、 +CLI 新手引导是在 macOS、 Linux 或 Windows(通过 WSL2;强烈推荐)上设置 OpenClaw 的**推荐**方式。 它可在一次引导式流程中配置本地 Gateway 网关或远程 Gateway 网关连接,以及渠道、Skills 和工作区默认值。 @@ -42,7 +42,7 @@ openclaw agents add -设置向导包含一个 web search 步骤,你可以选择一个提供商 +CLI 新手引导包含一个 web search 步骤,你可以选择一个提供商 (Perplexity、Brave、Gemini、Grok 或 Kimi),并粘贴你的 API 密钥,以便智能体 可以使用 `web_search`。你也可以稍后通过 `openclaw configure --section web` 进行配置。文档:[Web 工具](/tools/web)。 @@ -50,7 +50,7 @@ openclaw agents add ## 快速开始与高级模式 -向导开始时会让你选择**快速开始**(默认值)或**高级模式**(完全控制)。 +新手引导开始时会让你选择**快速开始**(默认值)或**高级模式**(完全控制)。 @@ -68,7 +68,7 @@ openclaw agents add -## 向导会配置什么 +## 新手引导会配置什么 **本地模式(默认)**会引导你完成以下步骤: @@ -91,9 +91,9 @@ openclaw agents add 7. **Skills** —— 安装推荐的 Skills 和可选依赖项。 -重新运行向导**不会**清除任何内容,除非你显式选择 **Reset**(或传入 `--reset`)。 +重新运行新手引导**不会**清除任何内容,除非你显式选择 **Reset**(或传入 `--reset`)。 CLI `--reset` 默认会重置配置、凭证和会话;如需包含工作区,请使用 `--reset-scope full`。 -如果配置无效或包含旧版键,向导会先要求你运行 `openclaw doctor`。 +如果配置无效或包含旧版键,新手引导会先要求你运行 `openclaw doctor`。 **远程模式**只会配置本地客户端以连接到其他地方的 Gateway 网关。 @@ -102,7 +102,7 @@ CLI `--reset` 默认会重置配置、凭证和会话;如需包含工作区, ## 添加另一个智能体 使用 `openclaw agents add ` 创建一个单独的智能体,它拥有自己的工作区、 -会话和认证配置文件。不带 `--workspace` 运行会启动向导。 +会话和认证配置文件。不带 `--workspace` 运行会启动新手引导。 它会设置: @@ -113,7 +113,7 @@ CLI `--reset` 默认会重置配置、凭证和会话;如需包含工作区, 说明: - 默认工作区遵循 `~/.openclaw/workspace-`。 -- 添加 `bindings` 以路由入站消息(向导可以完成这项操作)。 +- 添加 `bindings` 以路由入站消息(新手引导可以完成这项操作)。 - 非交互式标志:`--model`、`--agent-dir`、`--bind`、`--non-interactive`。 ## 完整参考 @@ -122,7 +122,7 @@ CLI `--reset` 默认会重置配置、凭证和会话;如需包含工作区, [CLI 设置参考](/start/wizard-cli-reference)。 有关非交互式示例,请参见 [CLI 自动化](/start/wizard-cli-automation)。 有关更深入的技术参考(包括 RPC 细节),请参见 -[向导参考](/reference/wizard)。 +[新手引导参考](/reference/wizard)。 ## 相关文档 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/byteplus/index.ts b/extensions/byteplus/index.ts index d32263014c6..d91fb87f1aa 100644 --- a/extensions/byteplus/index.ts +++ b/extensions/byteplus/index.ts @@ -1,10 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { - buildBytePlusCodingProvider, - buildBytePlusProvider, -} from "../../src/agents/models-config.providers.static.js"; import { ensureModelAllowlistEntry } from "../../src/commands/model-allowlist.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildBytePlusCodingProvider, buildBytePlusProvider } from "./provider-catalog.js"; const PROVIDER_ID = "byteplus"; const BYTEPLUS_DEFAULT_MODEL_REF = "byteplus-plan/ark-code-latest"; diff --git a/extensions/byteplus/provider-catalog.ts b/extensions/byteplus/provider-catalog.ts new file mode 100644 index 00000000000..77cca06a2db --- /dev/null +++ b/extensions/byteplus/provider-catalog.ts @@ -0,0 +1,24 @@ +import { + buildBytePlusModelDefinition, + BYTEPLUS_BASE_URL, + BYTEPLUS_CODING_BASE_URL, + BYTEPLUS_CODING_MODEL_CATALOG, + BYTEPLUS_MODEL_CATALOG, +} from "../../src/agents/byteplus-models.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +export function buildBytePlusProvider(): ModelProviderConfig { + return { + baseUrl: BYTEPLUS_BASE_URL, + api: "openai-completions", + models: BYTEPLUS_MODEL_CATALOG.map(buildBytePlusModelDefinition), + }; +} + +export function buildBytePlusCodingProvider(): ModelProviderConfig { + return { + baseUrl: BYTEPLUS_CODING_BASE_URL, + api: "openai-completions", + models: BYTEPLUS_CODING_MODEL_CATALOG.map(buildBytePlusModelDefinition), + }; +} 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/accounts.ts b/extensions/discord/src/accounts.ts index a623e97446f..6e9d58c97de 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -3,10 +3,12 @@ import type { DiscordAccountConfig, DiscordActionConfig, } from "openclaw/plugin-sdk/discord"; -import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js"; -import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; -import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import { + createAccountActionGate, + createAccountListHelpers, + normalizeAccountId, + resolveAccountEntry, +} from "../../../src/plugin-sdk-internal/accounts.js"; import { resolveDiscordToken } from "./token.js"; export type ResolvedDiscordAccount = { diff --git a/extensions/discord/src/draft-chunking.ts b/extensions/discord/src/draft-chunking.ts index 1d56841577a..a6461412ae7 100644 --- a/extensions/discord/src/draft-chunking.ts +++ b/extensions/discord/src/draft-chunking.ts @@ -1,5 +1,5 @@ import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; +import { type OpenClawConfig } from "../../../src/config/config.js"; import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; import { normalizeAccountId } from "../../../src/routing/session-key.js"; import { DISCORD_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 4ac49c92119..97401cec0d8 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -4,6 +4,7 @@ import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-regi import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js"; import type { OpenClawConfig } from "../../../../src/config/config.js"; import * as pluginCommandsModule from "../../../../src/plugins/commands.js"; +import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js"; import { createDiscordNativeCommand } from "./native-command.js"; import { createMockCommandInteraction, @@ -153,6 +154,7 @@ async function expectBoundStatusCommandDispatch(params: { describe("Discord native plugin command dispatch", () => { beforeEach(() => { vi.restoreAllMocks(); + clearPluginCommands(); persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset(); persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset(); @@ -162,6 +164,127 @@ describe("Discord native plugin command dispatch", () => { }); }); + it("executes plugin commands from the real registry through the native Discord command path", async () => { + const cfg = createConfig(); + const commandSpec: NativeCommandSpec = { + name: "pair", + description: "Pair", + acceptsArgs: true, + }; + const command = createDiscordNativeCommand({ + command: commandSpec, + cfg, + discordConfig: cfg.channels?.discord ?? {}, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); + const interaction = createInteraction(); + + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + description: "Pair device", + acceptsArgs: true, + requireAuth: false, + handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), + }), + ).toEqual({ ok: true }); + + const dispatchSpy = vi + .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") + .mockResolvedValue({} as never); + + await (command as { run: (interaction: unknown) => Promise }).run( + Object.assign(interaction, { + options: { + getString: () => "now", + getBoolean: () => null, + getFocused: () => "", + }, + }) as unknown, + ); + + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: "paired:now" }), + ); + }); + + it("blocks unauthorized Discord senders before requireAuth:false plugin commands execute", async () => { + const cfg = { + commands: { + allowFrom: { + discord: ["user:123456789012345678"], + }, + }, + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + "345678901234567890": { + channels: { + "234567890123456789": { + allow: true, + requireMention: false, + }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + const commandSpec: NativeCommandSpec = { + name: "pair", + description: "Pair", + acceptsArgs: true, + }; + const command = createDiscordNativeCommand({ + command: commandSpec, + cfg, + discordConfig: cfg.channels?.discord ?? {}, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); + const interaction = createInteraction({ + channelType: ChannelType.GuildText, + channelId: "234567890123456789", + guildId: "345678901234567890", + guildName: "Test Guild", + }); + interaction.user.id = "999999999999999999"; + interaction.options.getString.mockReturnValue("now"); + + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + description: "Pair device", + acceptsArgs: true, + requireAuth: false, + handler: async ({ args }) => ({ text: `open:${args ?? ""}` }), + }), + ).toEqual({ ok: true }); + + const executeSpy = vi.spyOn(pluginCommandsModule, "executePluginCommand"); + const dispatchSpy = vi + .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") + .mockResolvedValue({} as never); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(executeSpy).not.toHaveBeenCalled(); + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: "You are not authorized to use this command.", + ephemeral: true, + }), + ); + }); + it("executes matched plugin commands directly without invoking the agent dispatcher", async () => { const cfg = createConfig(); const commandSpec: NativeCommandSpec = { 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.registry.test.ts b/extensions/discord/src/monitor/provider.registry.test.ts new file mode 100644 index 00000000000..bffe979973b --- /dev/null +++ b/extensions/discord/src/monitor/provider.registry.test.ts @@ -0,0 +1,366 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; + +type NativeCommandSpecMock = { + name: string; + description: string; + acceptsArgs: boolean; +}; + +function baseDiscordAccountConfig() { + return { + commands: { native: true, nativeSkills: false }, + voice: { enabled: false }, + agentComponents: { enabled: false }, + execApprovals: { enabled: false }, + }; +} + +const { + clientConstructorOptionsMock, + clientFetchUserMock, + clientHandleDeployRequestMock, + createDiscordAutoPresenceControllerMock, + createDiscordMessageHandlerMock, + createDiscordNativeCommandMock, + createNoopThreadBindingManagerMock, + createThreadBindingManagerMock, + getAcpSessionStatusMock, + listNativeCommandSpecsForConfigMock, + listSkillCommandsForAgentsMock, + monitorLifecycleMock, + reconcileAcpThreadBindingsOnStartupMock, + resolveDiscordAccountMock, + resolveDiscordAllowlistConfigMock, + resolveNativeCommandsEnabledMock, + resolveNativeSkillsEnabledMock, +} = vi.hoisted(() => ({ + clientConstructorOptionsMock: vi.fn(), + clientFetchUserMock: vi.fn(async (_target: string) => ({ id: "bot-1" })), + clientHandleDeployRequestMock: vi.fn(async () => undefined), + createDiscordAutoPresenceControllerMock: vi.fn(() => ({ + enabled: false, + start: vi.fn(), + stop: vi.fn(), + refresh: vi.fn(), + runNow: vi.fn(), + })), + createDiscordMessageHandlerMock: vi.fn(() => + Object.assign( + vi.fn(async () => undefined), + { + deactivate: vi.fn(), + }, + ), + ), + createDiscordNativeCommandMock: vi.fn((params: { command: { name: string } }) => ({ + name: params.command.name, + })), + createNoopThreadBindingManagerMock: vi.fn(() => ({ stop: vi.fn() })), + createThreadBindingManagerMock: vi.fn(() => ({ stop: vi.fn() })), + getAcpSessionStatusMock: vi.fn( + async (_params: { cfg: OpenClawConfig; sessionKey: string; signal?: AbortSignal }) => ({ + state: "idle", + }), + ), + listNativeCommandSpecsForConfigMock: vi.fn<() => NativeCommandSpecMock[]>(() => [ + { name: "status", description: "Status", acceptsArgs: false }, + ]), + listSkillCommandsForAgentsMock: vi.fn(() => []), + monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => { + params.threadBindings.stop(); + }), + reconcileAcpThreadBindingsOnStartupMock: vi.fn(() => ({ + checked: 0, + removed: 0, + staleSessionKeys: [], + })), + resolveDiscordAccountMock: vi.fn(() => ({ + accountId: "default", + token: "cfg-token", + config: baseDiscordAccountConfig(), + })), + resolveDiscordAllowlistConfigMock: vi.fn(async () => ({ + guildEntries: undefined, + allowFrom: undefined, + })), + resolveNativeCommandsEnabledMock: vi.fn(() => true), + resolveNativeSkillsEnabledMock: vi.fn(() => false), +})); + +vi.mock("@buape/carbon", () => { + class ReadyListener {} + class RateLimitError extends Error { + status = 429; + retryAfter = 0; + scope: string | null = null; + bucket: string | null = null; + } + class Client { + listeners: unknown[]; + rest: { put: ReturnType }; + constructor(options: unknown, handlers: { listeners?: unknown[] }) { + clientConstructorOptionsMock(options); + this.listeners = handlers.listeners ?? []; + this.rest = { put: vi.fn(async () => undefined) }; + } + async handleDeployRequest() { + return await clientHandleDeployRequestMock(); + } + async fetchUser(target: string) { + return await clientFetchUserMock(target); + } + getPlugin() { + return undefined; + } + } + return { Client, RateLimitError, ReadyListener }; +}); + +vi.mock("@buape/carbon/gateway", () => ({ + GatewayCloseCodes: { DisallowedIntents: 4014 }, +})); + +vi.mock("@buape/carbon/voice", () => ({ + VoicePlugin: class VoicePlugin {}, +})); + +vi.mock("../../../../src/acp/control-plane/manager.js", () => ({ + getAcpSessionManager: () => ({ + getSessionStatus: getAcpSessionStatusMock, + }), +})); + +vi.mock("../../../../src/auto-reply/chunk.js", () => ({ + resolveTextChunkLimit: () => 2000, +})); + +vi.mock("../../../../src/auto-reply/commands-registry.js", () => ({ + listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock, +})); + +vi.mock("../../../../src/auto-reply/skill-commands.js", () => ({ + listSkillCommandsForAgents: listSkillCommandsForAgentsMock, +})); + +vi.mock("../../../../src/config/commands.js", () => ({ + isNativeCommandsExplicitlyDisabled: () => false, + resolveNativeCommandsEnabled: resolveNativeCommandsEnabledMock, + resolveNativeSkillsEnabled: resolveNativeSkillsEnabledMock, +})); + +vi.mock("../../../../src/config/config.js", () => ({ + loadConfig: () => ({}), +})); + +vi.mock("../../../../src/globals.js", () => ({ + danger: (value: string) => value, + isVerbose: () => false, + logVerbose: vi.fn(), + shouldLogVerbose: () => false, + warn: (value: string) => value, +})); + +vi.mock("../../../../src/infra/errors.js", () => ({ + formatErrorMessage: (error: unknown) => String(error), +})); + +vi.mock("../../../../src/infra/retry-policy.js", () => ({ + createDiscordRetryRunner: () => async (run: () => Promise) => run(), +})); + +vi.mock("../../../../src/logging/subsystem.js", () => ({ + createSubsystemLogger: () => { + const logger = { + child: vi.fn(() => logger), + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }; + return logger; + }, +})); + +vi.mock("../../../../src/runtime.js", () => ({ + createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }), +})); + +vi.mock("../accounts.js", () => ({ + resolveDiscordAccount: resolveDiscordAccountMock, +})); + +vi.mock("../probe.js", () => ({ + fetchDiscordApplicationId: async () => "app-1", +})); + +vi.mock("../token.js", () => ({ + normalizeDiscordToken: (value?: string) => value, +})); + +vi.mock("../voice/command.js", () => ({ + createDiscordVoiceCommand: () => ({ name: "voice-command" }), +})); + +vi.mock("./agent-components.js", () => ({ + createAgentComponentButton: () => ({ id: "btn" }), + createAgentSelectMenu: () => ({ id: "menu" }), + createDiscordComponentButton: () => ({ id: "btn2" }), + createDiscordComponentChannelSelect: () => ({ id: "channel" }), + createDiscordComponentMentionableSelect: () => ({ id: "mentionable" }), + createDiscordComponentModal: () => ({ id: "modal" }), + createDiscordComponentRoleSelect: () => ({ id: "role" }), + createDiscordComponentStringSelect: () => ({ id: "string" }), + createDiscordComponentUserSelect: () => ({ id: "user" }), +})); + +vi.mock("./auto-presence.js", () => ({ + createDiscordAutoPresenceController: createDiscordAutoPresenceControllerMock, +})); + +vi.mock("./commands.js", () => ({ + resolveDiscordSlashCommandConfig: () => ({ ephemeral: false }), +})); + +vi.mock("./exec-approvals.js", () => ({ + createExecApprovalButton: () => ({ id: "exec-approval" }), + DiscordExecApprovalHandler: class DiscordExecApprovalHandler { + async start() { + return undefined; + } + async stop() { + return undefined; + } + }, +})); + +vi.mock("./gateway-plugin.js", () => ({ + createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }), +})); + +vi.mock("./listeners.js", () => ({ + DiscordMessageListener: class DiscordMessageListener {}, + DiscordPresenceListener: class DiscordPresenceListener {}, + DiscordReactionListener: class DiscordReactionListener {}, + DiscordReactionRemoveListener: class DiscordReactionRemoveListener {}, + DiscordThreadUpdateListener: class DiscordThreadUpdateListener {}, + registerDiscordListener: vi.fn(), +})); + +vi.mock("./message-handler.js", () => ({ + createDiscordMessageHandler: createDiscordMessageHandlerMock, +})); + +vi.mock("./native-command.js", () => ({ + createDiscordCommandArgFallbackButton: () => ({ id: "arg-fallback" }), + createDiscordModelPickerFallbackButton: () => ({ id: "model-fallback-btn" }), + createDiscordModelPickerFallbackSelect: () => ({ id: "model-fallback-select" }), + createDiscordNativeCommand: createDiscordNativeCommandMock, +})); + +vi.mock("./presence.js", () => ({ + resolveDiscordPresenceUpdate: () => undefined, +})); + +vi.mock("./provider.allowlist.js", () => ({ + resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock, +})); + +vi.mock("./provider.lifecycle.js", () => ({ + runDiscordGatewayLifecycle: monitorLifecycleMock, +})); + +vi.mock("./rest-fetch.js", () => ({ + resolveDiscordRestFetch: () => async () => undefined, +})); + +vi.mock("./thread-bindings.js", () => ({ + createNoopThreadBindingManager: createNoopThreadBindingManagerMock, + createThreadBindingManager: createThreadBindingManagerMock, + reconcileAcpThreadBindingsOnStartup: reconcileAcpThreadBindingsOnStartupMock, +})); + +describe("monitorDiscordProvider real plugin registry", () => { + const baseRuntime = (): RuntimeEnv => ({ + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }); + + const baseConfig = (): OpenClawConfig => + ({ + channels: { + discord: { + accounts: { + default: {}, + }, + }, + }, + }) as OpenClawConfig; + + beforeEach(() => { + clearPluginCommands(); + clientConstructorOptionsMock.mockClear(); + clientFetchUserMock.mockClear().mockResolvedValue({ id: "bot-1" }); + clientHandleDeployRequestMock.mockClear().mockResolvedValue(undefined); + createDiscordAutoPresenceControllerMock.mockClear(); + createDiscordMessageHandlerMock.mockClear(); + createDiscordNativeCommandMock.mockClear(); + createNoopThreadBindingManagerMock.mockClear(); + createThreadBindingManagerMock.mockClear(); + getAcpSessionStatusMock.mockClear().mockResolvedValue({ state: "idle" }); + listNativeCommandSpecsForConfigMock + .mockClear() + .mockReturnValue([{ name: "status", description: "Status", acceptsArgs: false }]); + listSkillCommandsForAgentsMock.mockClear().mockReturnValue([]); + monitorLifecycleMock.mockClear().mockImplementation(async (params) => { + params.threadBindings.stop(); + }); + reconcileAcpThreadBindingsOnStartupMock.mockClear().mockReturnValue({ + checked: 0, + removed: 0, + staleSessionKeys: [], + }); + resolveDiscordAccountMock.mockClear().mockReturnValue({ + accountId: "default", + token: "cfg-token", + config: baseDiscordAccountConfig(), + }); + resolveDiscordAllowlistConfigMock.mockClear().mockResolvedValue({ + guildEntries: undefined, + allowFrom: undefined, + }); + resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true); + resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false); + }); + + it("registers plugin commands from the real registry as native Discord commands", async () => { + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + description: "Pair device", + acceptsArgs: true, + requireAuth: false, + handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), + }), + ).toEqual({ ok: true }); + + const { monitorDiscordProvider } = await import("./provider.js"); + + await monitorDiscordProvider({ + config: baseConfig(), + runtime: baseRuntime(), + }); + + const commandNames = (createDiscordNativeCommandMock.mock.calls as Array) + .map((call) => (call[0] as { command?: { name?: string } } | undefined)?.command?.name) + .filter((value): value is string => typeof value === "string"); + + expect(commandNames).toContain("status"); + expect(commandNames).toContain("pair"); + expect(clientHandleDeployRequestMock).toHaveBeenCalledTimes(1); + expect(monitorLifecycleMock).toHaveBeenCalledTimes(1); + }); +}); 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/setup-core.ts b/extensions/discord/src/setup-core.ts index 3c9ab69059b..6b644fe87c6 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,22 +1,23 @@ +import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; import { applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + formatDocsLink, migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import { + normalizeAccountId, noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, patchChannelConfigForAccount, setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; + type OpenClawConfig, +} from "../../../src/plugin-sdk-internal/setup.js"; +import { + type ChannelSetupAdapter, + type ChannelSetupDmPolicy, + type ChannelSetupWizard, +} from "../../../src/plugin-sdk-internal/setup.js"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js"; diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index ce7c6e789e4..2a59cbb1ed0 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,19 +1,21 @@ import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, noteChannelLookupFailure, noteChannelLookupSummary, + type OpenClawConfig, parseMentionOrPrefixedId, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, resolveSetupAccountId, setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; + type WizardPrompter, +} from "../../../src/plugin-sdk-internal/setup.js"; +import { + type ChannelSetupDmPolicy, + type ChannelSetupWizard, +} from "../../../src/plugin-sdk-internal/setup.js"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, 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/discord/src/token.ts b/extensions/discord/src/token.ts index 8f942c6920f..aff802f3ded 100644 --- a/extensions/discord/src/token.ts +++ b/extensions/discord/src/token.ts @@ -1,4 +1,4 @@ -import type { BaseTokenResolution } from "../../../src/channels/plugins/types.js"; +import type { BaseTokenResolution } from "../../../src/channels/plugins/types.core.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { normalizeResolvedSecretInputString } from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; diff --git a/extensions/elevenlabs/index.ts b/extensions/elevenlabs/index.ts new file mode 100644 index 00000000000..49d792df20f --- /dev/null +++ b/extensions/elevenlabs/index.ts @@ -0,0 +1,14 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildElevenLabsSpeechProvider } from "../../src/tts/providers/elevenlabs.js"; + +const elevenLabsPlugin = { + id: "elevenlabs", + name: "ElevenLabs Speech", + description: "Bundled ElevenLabs speech provider", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerSpeechProvider(buildElevenLabsSpeechProvider()); + }, +}; + +export default elevenLabsPlugin; diff --git a/extensions/elevenlabs/openclaw.plugin.json b/extensions/elevenlabs/openclaw.plugin.json new file mode 100644 index 00000000000..3015fa282a2 --- /dev/null +++ b/extensions/elevenlabs/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "elevenlabs", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/elevenlabs/package.json b/extensions/elevenlabs/package.json new file mode 100644 index 00000000000..d4b5d32f16c --- /dev/null +++ b/extensions/elevenlabs/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/elevenlabs-speech", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw ElevenLabs speech plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/huggingface/index.ts b/extensions/huggingface/index.ts index c1cea578349..63598ce0236 100644 --- a/extensions/huggingface/index.ts +++ b/extensions/huggingface/index.ts @@ -1,10 +1,10 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildHuggingfaceProvider } from "../../src/agents/models-config.providers.discovery.js"; import { applyHuggingfaceConfig, HUGGINGFACE_DEFAULT_MODEL_REF, } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildHuggingfaceProvider } from "./provider-catalog.js"; const PROVIDER_ID = "huggingface"; diff --git a/extensions/huggingface/provider-catalog.ts b/extensions/huggingface/provider-catalog.ts new file mode 100644 index 00000000000..5dc87b751df --- /dev/null +++ b/extensions/huggingface/provider-catalog.ts @@ -0,0 +1,22 @@ +import { + buildHuggingfaceModelDefinition, + discoverHuggingfaceModels, + HUGGINGFACE_BASE_URL, + HUGGINGFACE_MODEL_CATALOG, +} from "../../src/agents/huggingface-models.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +export async function buildHuggingfaceProvider( + discoveryApiKey?: string, +): Promise { + const resolvedSecret = discoveryApiKey?.trim() ?? ""; + const models = + resolvedSecret !== "" + ? await discoverHuggingfaceModels(resolvedSecret) + : HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition); + return { + baseUrl: HUGGINGFACE_BASE_URL, + api: "openai-completions", + models, + }; +} 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/accounts.ts b/extensions/imessage/src/accounts.ts index 1a6ca8bceb9..21c3c36d356 100644 --- a/extensions/imessage/src/accounts.ts +++ b/extensions/imessage/src/accounts.ts @@ -1,7 +1,10 @@ -import { normalizeAccountId, type IMessageAccountConfig } from "openclaw/plugin-sdk/imessage"; -import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import type { IMessageAccountConfig } from "openclaw/plugin-sdk/imessage"; +import { + type OpenClawConfig, + createAccountListHelpers, + normalizeAccountId, + resolveAccountEntry, +} from "../../../src/plugin-sdk-internal/accounts.js"; export type ResolvedIMessageAccount = { accountId: string; diff --git a/extensions/imessage/src/probe.test.ts b/extensions/imessage/src/probe.test.ts index 5d676327c11..ef69337984b 100644 --- a/extensions/imessage/src/probe.test.ts +++ b/extensions/imessage/src/probe.test.ts @@ -1,36 +1,30 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as onboardHelpers from "../../../src/commands/onboard-helpers.js"; +import * as execModule from "../../../src/process/exec.js"; +import * as clientModule from "./client.js"; import { probeIMessage } from "./probe.js"; -const detectBinaryMock = vi.hoisted(() => vi.fn()); -const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); -const createIMessageRpcClientMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../../src/commands/onboard-helpers.js", () => ({ - detectBinary: (...args: unknown[]) => detectBinaryMock(...args), -})); - -vi.mock("../../../src/process/exec.js", () => ({ - runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), -})); - -vi.mock("./client.js", () => ({ - createIMessageRpcClient: (...args: unknown[]) => createIMessageRpcClientMock(...args), -})); - beforeEach(() => { - detectBinaryMock.mockClear().mockResolvedValue(true); - runCommandWithTimeoutMock.mockClear().mockResolvedValue({ + vi.restoreAllMocks(); + vi.spyOn(onboardHelpers, "detectBinary").mockResolvedValue(true); + vi.spyOn(execModule, "runCommandWithTimeout").mockResolvedValue({ stdout: "", stderr: 'unknown command "rpc" for "imsg"', code: 1, signal: null, killed: false, + termination: "exit", }); - createIMessageRpcClientMock.mockClear(); }); describe("probeIMessage", () => { it("marks unknown rpc subcommand as fatal", async () => { + const createIMessageRpcClientMock = vi + .spyOn(clientModule, "createIMessageRpcClient") + .mockResolvedValue({ + request: vi.fn(), + stop: vi.fn(), + } as unknown as Awaited>); const result = await probeIMessage(1000, { cliPath: "imsg" }); expect(result.ok).toBe(false); expect(result.fatal).toBe(true); 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/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 38f280852c0..ada78cc9add 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,20 +1,21 @@ import { applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + formatDocsLink, migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import { + normalizeAccountId, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; + type OpenClawConfig, + type WizardPrompter, +} from "../../../src/plugin-sdk-internal/setup.js"; +import type { + ChannelSetupAdapter, + ChannelSetupDmPolicy, + ChannelSetupWizard, +} from "../../../src/plugin-sdk-internal/setup.js"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 0d0de246d7b..b8487dff54d 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,16 +1,18 @@ import { + DEFAULT_ACCOUNT_ID, + detectBinary, + formatDocsLink, + type OpenClawConfig, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { detectBinary } from "../../../src/commands/onboard-helpers.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; + type WizardPrompter, +} from "../../../src/plugin-sdk-internal/setup.js"; +import type { + ChannelSetupDmPolicy, + ChannelSetupWizard, +} from "../../../src/plugin-sdk-internal/setup.js"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, diff --git a/extensions/kilocode/index.ts b/extensions/kilocode/index.ts index 5fff1fd061b..1eba870856c 100644 --- a/extensions/kilocode/index.ts +++ b/extensions/kilocode/index.ts @@ -1,5 +1,4 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildKilocodeProviderWithDiscovery } from "../../src/agents/models-config.providers.discovery.js"; import { createKilocodeWrapper, isProxyReasoningUnsupported, @@ -9,6 +8,7 @@ import { KILOCODE_DEFAULT_MODEL_REF, } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildKilocodeProviderWithDiscovery } from "./provider-catalog.js"; const PROVIDER_ID = "kilocode"; diff --git a/extensions/kilocode/provider-catalog.ts b/extensions/kilocode/provider-catalog.ts new file mode 100644 index 00000000000..696b351c530 --- /dev/null +++ b/extensions/kilocode/provider-catalog.ts @@ -0,0 +1,34 @@ +import { discoverKilocodeModels } from "../../src/agents/kilocode-models.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import { + KILOCODE_BASE_URL, + KILOCODE_DEFAULT_CONTEXT_WINDOW, + KILOCODE_DEFAULT_COST, + KILOCODE_DEFAULT_MAX_TOKENS, + KILOCODE_MODEL_CATALOG, +} from "../../src/providers/kilocode-shared.js"; + +export function buildKilocodeProvider(): ModelProviderConfig { + return { + baseUrl: KILOCODE_BASE_URL, + api: "openai-completions", + models: KILOCODE_MODEL_CATALOG.map((model) => ({ + id: model.id, + name: model.name, + reasoning: model.reasoning, + input: model.input, + cost: KILOCODE_DEFAULT_COST, + contextWindow: model.contextWindow ?? KILOCODE_DEFAULT_CONTEXT_WINDOW, + maxTokens: model.maxTokens ?? KILOCODE_DEFAULT_MAX_TOKENS, + })), + }; +} + +export async function buildKilocodeProviderWithDiscovery(): Promise { + const models = await discoverKilocodeModels(); + return { + baseUrl: KILOCODE_BASE_URL, + api: "openai-completions", + models, + }; +} diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index 853eee98bef..42853a16c0c 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -1,8 +1,8 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildKimiCodingProvider } from "../../src/agents/models-config.providers.static.js"; import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { isRecord } from "../../src/utils.js"; +import { buildKimiCodingProvider } from "./provider-catalog.js"; const PROVIDER_ID = "kimi-coding"; diff --git a/extensions/kimi-coding/provider-catalog.ts b/extensions/kimi-coding/provider-catalog.ts new file mode 100644 index 00000000000..f570df20777 --- /dev/null +++ b/extensions/kimi-coding/provider-catalog.ts @@ -0,0 +1,34 @@ +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"; +const KIMI_CODING_USER_AGENT = "claude-code/0.1.0"; +export const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5"; +const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144; +const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768; +const KIMI_CODING_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export function buildKimiCodingProvider(): ModelProviderConfig { + return { + baseUrl: KIMI_CODING_BASE_URL, + api: "anthropic-messages", + headers: { + "User-Agent": KIMI_CODING_USER_AGENT, + }, + models: [ + { + id: KIMI_CODING_DEFAULT_MODEL_ID, + name: "Kimi for Coding", + reasoning: true, + input: ["text", "image"], + cost: KIMI_CODING_DEFAULT_COST, + contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW, + maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS, + }, + ], + }; +} diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts index 49feb7929ff..6d21ec69654 100644 --- a/extensions/llm-task/src/llm-task-tool.test.ts +++ b/extensions/llm-task/src/llm-task-tool.test.ts @@ -1,17 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; - -vi.mock("openclaw/extension-api", () => { - return { - runEmbeddedPiAgent: vi.fn(async () => ({ - meta: { startedAt: Date.now() }, - payloads: [{ text: "{}" }], - })), - }; -}); - -import { runEmbeddedPiAgent } from "openclaw/extension-api"; import { createLlmTaskTool } from "./llm-task-tool.js"; +const runEmbeddedPiAgent = vi.fn(async () => ({ + meta: { startedAt: Date.now() }, + payloads: [{ text: "{}" }], +})); + // oxlint-disable-next-line typescript/no-explicit-any function fakeApi(overrides: any = {}) { return { @@ -22,7 +16,12 @@ function fakeApi(overrides: any = {}) { agents: { defaults: { workspace: "/tmp", model: { primary: "openai-codex/gpt-5.2" } } }, }, pluginConfig: {}, - runtime: { version: "test" }, + runtime: { + version: "test", + agent: { + runEmbeddedPiAgent, + }, + }, logger: { debug() {}, info() {}, warn() {}, error() {} }, registerTool() {}, ...overrides, diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index d79e0a51130..bcc422290c6 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { Type } from "@sinclair/typebox"; import Ajv from "ajv"; -import { runEmbeddedPiAgent } from "openclaw/extension-api"; import { formatThinkingLevels, formatXHighModelHint, @@ -179,7 +178,7 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { const sessionId = `llm-task-${Date.now()}`; const sessionFile = path.join(tmpDir, "session.json"); - const result = await runEmbeddedPiAgent({ + const result = await api.runtime.agent.runEmbeddedPiAgent({ sessionId, sessionFile, workspaceDir: api.config?.agents?.defaults?.workspace ?? process.cwd(), diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 21d090846b0..0ed5c0eda97 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -44,6 +44,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerCli() {}, registerService() {}, registerProvider() {}, + registerSpeechProvider() {}, registerWebSearchProvider() {}, registerInteractiveHandler() {}, registerHook() {}, diff --git a/extensions/microsoft/index.ts b/extensions/microsoft/index.ts new file mode 100644 index 00000000000..358ea2057a0 --- /dev/null +++ b/extensions/microsoft/index.ts @@ -0,0 +1,14 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildMicrosoftSpeechProvider } from "../../src/tts/providers/microsoft.js"; + +const microsoftPlugin = { + id: "microsoft", + name: "Microsoft Speech", + description: "Bundled Microsoft speech provider", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerSpeechProvider(buildMicrosoftSpeechProvider()); + }, +}; + +export default microsoftPlugin; diff --git a/extensions/microsoft/openclaw.plugin.json b/extensions/microsoft/openclaw.plugin.json new file mode 100644 index 00000000000..85a130c463a --- /dev/null +++ b/extensions/microsoft/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "microsoft", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/microsoft/package.json b/extensions/microsoft/package.json new file mode 100644 index 00000000000..400095cc1f0 --- /dev/null +++ b/extensions/microsoft/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/microsoft-speech", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Microsoft speech plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 604e8627d22..e87a60556fa 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -8,10 +8,6 @@ import { } from "openclaw/plugin-sdk/minimax-portal-auth"; import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; import { MINIMAX_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js"; -import { - buildMinimaxPortalProvider, - buildMinimaxProvider, -} from "../../src/agents/models-config.providers.static.js"; import { applyMinimaxApiConfig, applyMinimaxApiConfigCn, @@ -19,6 +15,7 @@ import { import { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; +import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js"; const API_PROVIDER_ID = "minimax"; const PORTAL_PROVIDER_ID = "minimax-portal"; diff --git a/extensions/minimax/provider-catalog.ts b/extensions/minimax/provider-catalog.ts new file mode 100644 index 00000000000..83c1c46df13 --- /dev/null +++ b/extensions/minimax/provider-catalog.ts @@ -0,0 +1,77 @@ +import type { ModelDefinitionConfig, ModelProviderConfig } from "../../src/config/types.models.js"; + +const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic"; +export const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5"; +const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; +const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; +const MINIMAX_DEFAULT_MAX_TOKENS = 8192; +const MINIMAX_API_COST = { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0.12, +}; + +function buildMinimaxModel(params: { + id: string; + name: string; + reasoning: boolean; + input: ModelDefinitionConfig["input"]; +}): ModelDefinitionConfig { + return { + id: params.id, + name: params.name, + reasoning: params.reasoning, + input: params.input, + cost: MINIMAX_API_COST, + contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, + maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, + }; +} + +function buildMinimaxTextModel(params: { + id: string; + name: string; + reasoning: boolean; +}): ModelDefinitionConfig { + return buildMinimaxModel({ ...params, input: ["text"] }); +} + +function buildMinimaxCatalog(): ModelDefinitionConfig[] { + return [ + buildMinimaxModel({ + id: MINIMAX_DEFAULT_VISION_MODEL_ID, + name: "MiniMax VL 01", + reasoning: false, + input: ["text", "image"], + }), + buildMinimaxTextModel({ + id: MINIMAX_DEFAULT_MODEL_ID, + name: "MiniMax M2.5", + reasoning: true, + }), + buildMinimaxTextModel({ + id: "MiniMax-M2.5-highspeed", + name: "MiniMax M2.5 Highspeed", + reasoning: true, + }), + ]; +} + +export function buildMinimaxProvider(): ModelProviderConfig { + return { + baseUrl: MINIMAX_PORTAL_BASE_URL, + api: "anthropic-messages", + authHeader: true, + models: buildMinimaxCatalog(), + }; +} + +export function buildMinimaxPortalProvider(): ModelProviderConfig { + return { + baseUrl: MINIMAX_PORTAL_BASE_URL, + api: "anthropic-messages", + authHeader: true, + models: buildMinimaxCatalog(), + }; +} diff --git a/extensions/modelstudio/index.ts b/extensions/modelstudio/index.ts index 2e3e7c6b3c8..08e8730dfbc 100644 --- a/extensions/modelstudio/index.ts +++ b/extensions/modelstudio/index.ts @@ -1,11 +1,11 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildModelStudioProvider } from "../../src/agents/models-config.providers.static.js"; import { applyModelStudioConfig, applyModelStudioConfigCn, MODELSTUDIO_DEFAULT_MODEL_REF, } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildModelStudioProvider } from "./provider-catalog.js"; const PROVIDER_ID = "modelstudio"; diff --git a/extensions/modelstudio/provider-catalog.ts b/extensions/modelstudio/provider-catalog.ts new file mode 100644 index 00000000000..ea9f2b2ae72 --- /dev/null +++ b/extensions/modelstudio/provider-catalog.ts @@ -0,0 +1,93 @@ +import type { ModelDefinitionConfig, ModelProviderConfig } from "../../src/config/types.models.js"; + +export const MODELSTUDIO_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; +export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus"; +const MODELSTUDIO_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const MODELSTUDIO_MODEL_CATALOG: ReadonlyArray = [ + { + id: "qwen3.5-plus", + name: "qwen3.5-plus", + reasoning: false, + input: ["text", "image"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 1_000_000, + maxTokens: 65_536, + }, + { + id: "qwen3-max-2026-01-23", + name: "qwen3-max-2026-01-23", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 262_144, + maxTokens: 65_536, + }, + { + id: "qwen3-coder-next", + name: "qwen3-coder-next", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 262_144, + maxTokens: 65_536, + }, + { + id: "qwen3-coder-plus", + name: "qwen3-coder-plus", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 1_000_000, + maxTokens: 65_536, + }, + { + id: "MiniMax-M2.5", + name: "MiniMax-M2.5", + reasoning: true, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 1_000_000, + maxTokens: 65_536, + }, + { + id: "glm-5", + name: "glm-5", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 202_752, + maxTokens: 16_384, + }, + { + id: "glm-4.7", + name: "glm-4.7", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 202_752, + maxTokens: 16_384, + }, + { + id: "kimi-k2.5", + name: "kimi-k2.5", + reasoning: false, + input: ["text", "image"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 262_144, + maxTokens: 32_768, + }, +]; + +export function buildModelStudioProvider(): ModelProviderConfig { + return { + baseUrl: MODELSTUDIO_BASE_URL, + api: "openai-completions", + models: MODELSTUDIO_MODEL_CATALOG.map((model) => ({ ...model })), + }; +} diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 3b57a5134ba..94e01d3a069 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -1,4 +1,3 @@ -import { buildMoonshotProvider } from "../../src/agents/models-config.providers.static.js"; import { createMoonshotThinkingWrapper, resolveMoonshotThinkingType, @@ -16,6 +15,7 @@ import { MOONSHOT_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.mode import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +import { buildMoonshotProvider } from "./provider-catalog.js"; const PROVIDER_ID = "moonshot"; diff --git a/extensions/moonshot/provider-catalog.ts b/extensions/moonshot/provider-catalog.ts new file mode 100644 index 00000000000..86ab93e6e05 --- /dev/null +++ b/extensions/moonshot/provider-catalog.ts @@ -0,0 +1,30 @@ +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +export const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; +export const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5"; +const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; +const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; +const MOONSHOT_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export function buildMoonshotProvider(): ModelProviderConfig { + return { + baseUrl: MOONSHOT_BASE_URL, + api: "openai-completions", + models: [ + { + id: MOONSHOT_DEFAULT_MODEL_ID, + name: "Kimi K2.5", + reasoning: false, + input: ["text", "image"], + cost: MOONSHOT_DEFAULT_COST, + contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW, + maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS, + }, + ], + }; +} diff --git a/extensions/nvidia/index.ts b/extensions/nvidia/index.ts index afa83c4dff4..02df4f8e6a3 100644 --- a/extensions/nvidia/index.ts +++ b/extensions/nvidia/index.ts @@ -1,5 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildNvidiaProvider } from "../../src/agents/models-config.providers.static.js"; +import { buildNvidiaProvider } from "./provider-catalog.js"; const PROVIDER_ID = "nvidia"; diff --git a/extensions/nvidia/provider-catalog.ts b/extensions/nvidia/provider-catalog.ts new file mode 100644 index 00000000000..f506839fa33 --- /dev/null +++ b/extensions/nvidia/provider-catalog.ts @@ -0,0 +1,48 @@ +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; +const NVIDIA_DEFAULT_MODEL_ID = "nvidia/llama-3.1-nemotron-70b-instruct"; +const NVIDIA_DEFAULT_CONTEXT_WINDOW = 131072; +const NVIDIA_DEFAULT_MAX_TOKENS = 4096; +const NVIDIA_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export function buildNvidiaProvider(): ModelProviderConfig { + return { + baseUrl: NVIDIA_BASE_URL, + api: "openai-completions", + models: [ + { + id: NVIDIA_DEFAULT_MODEL_ID, + name: "NVIDIA Llama 3.1 Nemotron 70B Instruct", + reasoning: false, + input: ["text"], + cost: NVIDIA_DEFAULT_COST, + contextWindow: NVIDIA_DEFAULT_CONTEXT_WINDOW, + maxTokens: NVIDIA_DEFAULT_MAX_TOKENS, + }, + { + id: "meta/llama-3.3-70b-instruct", + name: "Meta Llama 3.3 70B Instruct", + reasoning: false, + input: ["text"], + cost: NVIDIA_DEFAULT_COST, + contextWindow: 131072, + maxTokens: 4096, + }, + { + id: "nvidia/mistral-nemo-minitron-8b-8k-instruct", + name: "NVIDIA Mistral NeMo Minitron 8B Instruct", + reasoning: false, + input: ["text"], + cost: NVIDIA_DEFAULT_COST, + contextWindow: 8192, + maxTokens: 2048, + }, + ], + }; +} diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 0609c597dc4..5386a37d270 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -1,21 +1,21 @@ import { - buildOllamaProvider, emptyPluginConfigSchema, - ensureOllamaModelPulled, - OLLAMA_DEFAULT_BASE_URL, - promptAndConfigureOllama, - configureOllamaNonInteractive, type OpenClawPluginApi, type ProviderAuthContext, type ProviderAuthMethodNonInteractiveContext, type ProviderAuthResult, 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"; +import { resolveOllamaApiBase } from "../../src/agents/ollama-models.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/index.ts b/extensions/openai/index.ts index 3a01aad8db9..cd528f72211 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -1,4 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildOpenAISpeechProvider } from "../../src/tts/providers/openai.js"; import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; import { buildOpenAIProvider } from "./openai-provider.js"; @@ -10,6 +11,7 @@ const openAIPlugin = { register(api: OpenClawPluginApi) { api.registerProvider(buildOpenAIProvider()); api.registerProvider(buildOpenAICodexProviderPlugin()); + api.registerSpeechProvider(buildOpenAISpeechProvider()); }, }; diff --git a/extensions/openai/openai-codex-catalog.ts b/extensions/openai/openai-codex-catalog.ts new file mode 100644 index 00000000000..ecea655547b --- /dev/null +++ b/extensions/openai/openai-codex-catalog.ts @@ -0,0 +1,11 @@ +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +export const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; + +export function buildOpenAICodexProvider(): ModelProviderConfig { + return { + baseUrl: OPENAI_CODEX_BASE_URL, + api: "openai-codex-responses", + models: [], + }; +} diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index c0ae2c12210..49c6f7272a9 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -10,12 +10,12 @@ 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"; import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { buildOpenAICodexProvider } from "./openai-codex-catalog.js"; import { cloneFirstTemplateModel, findCatalogTemplate, 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/openrouter/index.ts b/extensions/openrouter/index.ts index ec4afaa873c..0fdac10ea0e 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -6,7 +6,6 @@ import { type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; -import { buildOpenrouterProvider } from "../../src/agents/models-config.providers.static.js"; import { getOpenRouterModelCapabilities, loadOpenRouterModelCapabilities, @@ -21,6 +20,7 @@ import { OPENROUTER_DEFAULT_MODEL_REF, } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildOpenrouterProvider } from "./provider-catalog.js"; const PROVIDER_ID = "openrouter"; const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; diff --git a/extensions/openrouter/provider-catalog.ts b/extensions/openrouter/provider-catalog.ts new file mode 100644 index 00000000000..cfb5fecf8bf --- /dev/null +++ b/extensions/openrouter/provider-catalog.ts @@ -0,0 +1,48 @@ +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const OPENROUTER_DEFAULT_MODEL_ID = "auto"; +const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000; +const OPENROUTER_DEFAULT_MAX_TOKENS = 8192; +const OPENROUTER_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export function buildOpenrouterProvider(): ModelProviderConfig { + return { + baseUrl: OPENROUTER_BASE_URL, + api: "openai-completions", + models: [ + { + id: OPENROUTER_DEFAULT_MODEL_ID, + name: "OpenRouter Auto", + reasoning: false, + input: ["text", "image"], + cost: OPENROUTER_DEFAULT_COST, + contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW, + maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS, + }, + { + id: "openrouter/hunter-alpha", + name: "Hunter Alpha", + reasoning: true, + input: ["text"], + cost: OPENROUTER_DEFAULT_COST, + contextWindow: 1048576, + maxTokens: 65536, + }, + { + id: "openrouter/healer-alpha", + name: "Healer Alpha", + reasoning: true, + input: ["text", "image"], + cost: OPENROUTER_DEFAULT_COST, + contextWindow: 262144, + maxTokens: 65536, + }, + ], + }; +} 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/qianfan/index.ts b/extensions/qianfan/index.ts index 88b5fee122d..6ce5bd21008 100644 --- a/extensions/qianfan/index.ts +++ b/extensions/qianfan/index.ts @@ -1,7 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildQianfanProvider } from "../../src/agents/models-config.providers.static.js"; import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildQianfanProvider } from "./provider-catalog.js"; const PROVIDER_ID = "qianfan"; diff --git a/extensions/qianfan/provider-catalog.ts b/extensions/qianfan/provider-catalog.ts new file mode 100644 index 00000000000..f96fca8e14c --- /dev/null +++ b/extensions/qianfan/provider-catalog.ts @@ -0,0 +1,39 @@ +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2"; +export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2"; +const QIANFAN_DEFAULT_CONTEXT_WINDOW = 98304; +const QIANFAN_DEFAULT_MAX_TOKENS = 32768; +const QIANFAN_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export function buildQianfanProvider(): ModelProviderConfig { + return { + baseUrl: QIANFAN_BASE_URL, + api: "openai-completions", + models: [ + { + id: QIANFAN_DEFAULT_MODEL_ID, + name: "DEEPSEEK V3.2", + reasoning: true, + input: ["text"], + cost: QIANFAN_DEFAULT_COST, + contextWindow: QIANFAN_DEFAULT_CONTEXT_WINDOW, + maxTokens: QIANFAN_DEFAULT_MAX_TOKENS, + }, + { + id: "ernie-5.0-thinking-preview", + name: "ERNIE-5.0-Thinking-Preview", + reasoning: true, + input: ["text", "image"], + cost: QIANFAN_DEFAULT_COST, + contextWindow: 119000, + maxTokens: 64000, + }, + ], + }; +} diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index 774b1329acf..7c64c9b7683 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -9,13 +9,12 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agent import { QWEN_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js"; import { refreshQwenPortalCredentials } from "../../src/providers/qwen-portal-oauth.js"; import { loginQwenPortalOAuth } from "./oauth.js"; +import { buildQwenPortalProvider, QWEN_PORTAL_BASE_URL } from "./provider-catalog.js"; const PROVIDER_ID = "qwen-portal"; const PROVIDER_LABEL = "Qwen"; const DEFAULT_MODEL = "qwen-portal/coder-model"; -const DEFAULT_BASE_URL = "https://portal.qwen.ai/v1"; -const DEFAULT_CONTEXT_WINDOW = 128000; -const DEFAULT_MAX_TOKENS = 8192; +const DEFAULT_BASE_URL = QWEN_PORTAL_BASE_URL; function normalizeBaseUrl(value: string | undefined): string { const raw = value?.trim() || DEFAULT_BASE_URL; @@ -23,39 +22,11 @@ function normalizeBaseUrl(value: string | undefined): string { return withProtocol.endsWith("/v1") ? withProtocol : `${withProtocol.replace(/\/+$/, "")}/v1`; } -function buildModelDefinition(params: { - id: string; - name: string; - input: Array<"text" | "image">; -}) { - return { - id: params.id, - name: params.name, - reasoning: false, - input: params.input, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_WINDOW, - maxTokens: DEFAULT_MAX_TOKENS, - }; -} - function buildProviderCatalog(params: { baseUrl: string; apiKey: string }) { return { + ...buildQwenPortalProvider(), baseUrl: params.baseUrl, apiKey: params.apiKey, - api: "openai-completions" as const, - models: [ - buildModelDefinition({ - id: "coder-model", - name: "Qwen Coder", - input: ["text"], - }), - buildModelDefinition({ - id: "vision-model", - name: "Qwen Vision", - input: ["text", "image"], - }), - ], }; } diff --git a/extensions/qwen-portal-auth/provider-catalog.ts b/extensions/qwen-portal-auth/provider-catalog.ts new file mode 100644 index 00000000000..aa038c0810e --- /dev/null +++ b/extensions/qwen-portal-auth/provider-catalog.ts @@ -0,0 +1,46 @@ +import type { ModelDefinitionConfig, ModelProviderConfig } from "../../src/config/types.models.js"; + +export const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1"; +const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000; +const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192; +const QWEN_PORTAL_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +function buildModelDefinition(params: { + id: string; + name: string; + input: ModelDefinitionConfig["input"]; +}): ModelDefinitionConfig { + return { + id: params.id, + name: params.name, + reasoning: false, + input: params.input, + cost: QWEN_PORTAL_DEFAULT_COST, + contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW, + maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS, + }; +} + +export function buildQwenPortalProvider(): ModelProviderConfig { + return { + baseUrl: QWEN_PORTAL_BASE_URL, + api: "openai-completions", + models: [ + buildModelDefinition({ + id: "coder-model", + name: "Qwen Coder", + input: ["text"], + }), + buildModelDefinition({ + id: "vision-model", + name: "Qwen Vision", + input: ["text", "image"], + }), + ], + }; +} 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/accounts.ts b/extensions/signal/src/accounts.ts index 38316955edd..0bf9db0e79a 100644 --- a/extensions/signal/src/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -1,7 +1,10 @@ -import { normalizeAccountId, type SignalAccountConfig } from "openclaw/plugin-sdk/signal"; -import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import type { SignalAccountConfig } from "openclaw/plugin-sdk/signal"; +import { + type OpenClawConfig, + createAccountListHelpers, + normalizeAccountId, + resolveAccountEntry, +} from "../../../src/plugin-sdk-internal/accounts.js"; export type ResolvedSignalAccount = { accountId: string; diff --git a/extensions/signal/src/probe.test.ts b/extensions/signal/src/probe.test.ts index 7250c1de744..30816129107 100644 --- a/extensions/signal/src/probe.test.ts +++ b/extensions/signal/src/probe.test.ts @@ -1,27 +1,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as clientModule from "./client.js"; import { classifySignalCliLogLine } from "./daemon.js"; import { probeSignal } from "./probe.js"; -const signalCheckMock = vi.fn(); -const signalRpcRequestMock = vi.fn(); - -vi.mock("./client.js", () => ({ - signalCheck: (...args: unknown[]) => signalCheckMock(...args), - signalRpcRequest: (...args: unknown[]) => signalRpcRequestMock(...args), -})); - describe("probeSignal", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); }); it("extracts version from {version} result", async () => { - signalCheckMock.mockResolvedValueOnce({ + vi.spyOn(clientModule, "signalCheck").mockResolvedValueOnce({ ok: true, status: 200, error: null, }); - signalRpcRequestMock.mockResolvedValueOnce({ version: "0.13.22" }); + vi.spyOn(clientModule, "signalRpcRequest").mockResolvedValueOnce({ version: "0.13.22" }); const res = await probeSignal("http://127.0.0.1:8080", 1000); @@ -31,7 +24,7 @@ describe("probeSignal", () => { }); it("returns ok=false when /check fails", async () => { - signalCheckMock.mockResolvedValueOnce({ + vi.spyOn(clientModule, "signalCheck").mockResolvedValueOnce({ ok: false, status: 503, error: "HTTP 503", 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/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 40cc99add6e..7e78fbf64a5 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,22 +1,23 @@ import { applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + formatCliCommand, + formatDocsLink, migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import { + normalizeAccountId, + normalizeE164, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import { normalizeE164 } from "../../../src/utils.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; + type OpenClawConfig, + type WizardPrompter, +} from "../../../src/plugin-sdk-internal/setup.js"; +import type { + ChannelSetupAdapter, + ChannelSetupDmPolicy, + ChannelSetupWizard, +} from "../../../src/plugin-sdk-internal/setup.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index d3bd8e0b6de..5c40ba0788e 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,18 +1,20 @@ import { + DEFAULT_ACCOUNT_ID, + detectBinary, + formatCliCommand, + formatDocsLink, + installSignalCli, + type OpenClawConfig, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import { detectBinary } from "../../../src/commands/onboard-helpers.js"; -import { installSignalCli } from "../../../src/commands/signal-install.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; + type WizardPrompter, +} from "../../../src/plugin-sdk-internal/setup.js"; +import type { + ChannelSetupDmPolicy, + ChannelSetupWizard, +} from "../../../src/plugin-sdk-internal/setup.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, 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/accounts.ts b/extensions/slack/src/accounts.ts index 294bbf8956b..51faf8a4a6b 100644 --- a/extensions/slack/src/accounts.ts +++ b/extensions/slack/src/accounts.ts @@ -1,9 +1,12 @@ -import { normalizeChatType } from "../../../src/channels/chat-type.js"; -import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { SlackAccountConfig } from "../../../src/config/types.js"; -import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import type { SlackAccountConfig } from "openclaw/plugin-sdk/slack"; +import { + type OpenClawConfig, + createAccountListHelpers, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeChatType, + resolveAccountEntry, +} from "../../../src/plugin-sdk-internal/accounts.js"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js"; diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index b5723ea5130..c221cc9cebf 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -1,56 +1,18 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; -import { - createScopedAccountConfigAccessors, - formatAllowFromLowercase, -} from "openclaw/plugin-sdk/compat"; import { buildChannelConfigSchema, getChatChannelMeta, SlackConfigSchema, type ChannelPlugin, } from "openclaw/plugin-sdk/slack"; -import { inspectSlackAccount } from "./account-inspect.js"; -import { - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, - type ResolvedSlackAccount, -} from "./accounts.js"; +import { type ResolvedSlackAccount } from "./accounts.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; +import { isSlackPluginAccountConfigured, slackConfigAccessors, slackConfigBase } from "./shared.js"; async function loadSlackChannelRuntime() { return await import("./channel.runtime.js"); } -function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { - const mode = account.config.mode ?? "socket"; - const hasBotToken = Boolean(account.botToken?.trim()); - if (!hasBotToken) { - return false; - } - if (mode === "http") { - return Boolean(account.config.signingSecret?.trim()); - } - return Boolean(account.appToken?.trim()); -} - -const slackConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, -}); - -const slackConfigBase = createScopedChannelConfigBase({ - sectionKey: "slack", - listAccountIds: listSlackAccountIds, - resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultSlackAccountId, - clearBaseFields: ["botToken", "appToken", "name"], -}); - const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, })); @@ -87,12 +49,12 @@ export const slackSetupPlugin: ChannelPlugin = { configSchema: buildChannelConfigSchema(SlackConfigSchema), config: { ...slackConfigBase, - isConfigured: (account) => isSlackAccountConfigured(account), + isConfigured: (account) => isSlackPluginAccountConfigured(account), describeAccount: (account) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, - configured: isSlackAccountConfigured(account), + configured: isSlackPluginAccountConfigured(account), botTokenSource: account.botTokenSource, appTokenSource: account.appTokenSource, }), diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index a07608d836a..4a43055c142 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,11 +1,8 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, collectOpenGroupPolicyConfiguredRouteWarnings, - createScopedAccountConfigAccessors, - formatAllowFromLowercase, } from "openclaw/plugin-sdk/compat"; import { buildAgentSessionKey, @@ -32,11 +29,8 @@ import { } from "openclaw/plugin-sdk/slack"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; -import { inspectSlackAccount } from "./account-inspect.js"; import { listEnabledSlackAccounts, - listSlackAccountIds, - resolveDefaultSlackAccountId, resolveSlackAccount, resolveSlackReplyToMode, type ResolvedSlackAccount, @@ -52,6 +46,7 @@ import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { getSlackRuntime } from "./runtime.js"; import { fetchSlackScopes } from "./scopes.js"; import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; +import { isSlackPluginAccountConfigured, slackConfigAccessors, slackConfigBase } from "./shared.js"; import { parseSlackTarget } from "./targets.js"; import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; @@ -79,18 +74,6 @@ function getTokenForOperation( return botToken ?? userToken; } -function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { - const mode = account.config.mode ?? "socket"; - const hasBotToken = Boolean(account.botToken?.trim()); - if (!hasBotToken) { - return false; - } - if (mode === "http") { - return Boolean(account.config.signingSecret?.trim()); - } - return Boolean(account.appToken?.trim()); -} - type SlackSendFn = ReturnType["channel"]["slack"]["sendMessageSlack"]; function resolveSlackSendContext(params: { @@ -345,22 +328,6 @@ async function resolveSlackAllowlistNames(params: { return await resolveSlackUserAllowlist({ token, entries: params.entries }); } -const slackConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, -}); - -const slackConfigBase = createScopedChannelConfigBase({ - sectionKey: "slack", - listAccountIds: listSlackAccountIds, - resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultSlackAccountId, - clearBaseFields: ["botToken", "appToken", "name"], -}); - const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, })); @@ -425,12 +392,12 @@ export const slackPlugin: ChannelPlugin = { configSchema: buildChannelConfigSchema(SlackConfigSchema), config: { ...slackConfigBase, - isConfigured: (account) => isSlackAccountConfigured(account), + isConfigured: (account) => isSlackPluginAccountConfigured(account), describeAccount: (account) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, - configured: isSlackAccountConfigured(account), + configured: isSlackPluginAccountConfigured(account), botTokenSource: account.botTokenSource, appTokenSource: account.appTokenSource, }), @@ -722,7 +689,7 @@ export const slackPlugin: ChannelPlugin = { : resolveConfiguredFromRequiredCredentialStatuses(account, [ "botTokenStatus", "appTokenStatus", - ])) ?? isSlackAccountConfigured(account); + ])) ?? isSlackPluginAccountConfigured(account); const base = buildComputedAccountStatusSnapshot({ accountId: account.accountId, name: account.name, diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 58fc4d77184..b0883be083d 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1,334 +1 @@ -import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk"; -import { parseSlackBlocksInput } from "./blocks-input.js"; -import { buildSlackInteractiveBlocks } from "./blocks-render.js"; - -type SlackActionInvoke = ( - action: Record, - cfg: ChannelMessageActionContext["cfg"], - toolContext?: ChannelMessageActionContext["toolContext"], -) => Promise>; - -type InteractiveButtonStyle = "primary" | "secondary" | "success" | "danger"; - -type InteractiveReplyButton = { - label: string; - value: string; - style?: InteractiveButtonStyle; -}; - -type InteractiveReplyOption = { - label: string; - value: string; -}; - -type InteractiveReplyBlock = - | { type: "text"; text: string } - | { type: "buttons"; buttons: InteractiveReplyButton[] } - | { type: "select"; placeholder?: string; options: InteractiveReplyOption[] }; - -type InteractiveReply = { - blocks: InteractiveReplyBlock[]; -}; - -function readTrimmedString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed || undefined; -} - -function normalizeButtonStyle(value: unknown): InteractiveButtonStyle | undefined { - const style = readTrimmedString(value)?.toLowerCase(); - return style === "primary" || style === "secondary" || style === "success" || style === "danger" - ? style - : undefined; -} - -function normalizeInteractiveButton(raw: unknown): InteractiveReplyButton | undefined { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return undefined; - } - const record = raw as Record; - const label = readTrimmedString(record.label) ?? readTrimmedString(record.text); - const value = - readTrimmedString(record.value) ?? - readTrimmedString(record.callbackData) ?? - readTrimmedString(record.callback_data); - if (!label || !value) { - return undefined; - } - return { label, value, style: normalizeButtonStyle(record.style) }; -} - -function normalizeInteractiveOption(raw: unknown): InteractiveReplyOption | undefined { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return undefined; - } - const record = raw as Record; - const label = readTrimmedString(record.label) ?? readTrimmedString(record.text); - const value = readTrimmedString(record.value); - return label && value ? { label, value } : undefined; -} - -function normalizeInteractiveReply(raw: unknown): InteractiveReply | undefined { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return undefined; - } - const record = raw as Record; - const blocks = Array.isArray(record.blocks) - ? record.blocks - .map((entry) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - return undefined; - } - const block = entry as Record; - const type = readTrimmedString(block.type)?.toLowerCase(); - if (type === "text") { - const text = readTrimmedString(block.text); - return text ? ({ type: "text", text } as const) : undefined; - } - if (type === "buttons") { - const buttons = Array.isArray(block.buttons) - ? block.buttons - .map((button) => normalizeInteractiveButton(button)) - .filter((button): button is InteractiveReplyButton => Boolean(button)) - : []; - return buttons.length > 0 ? ({ type: "buttons", buttons } as const) : undefined; - } - if (type === "select") { - const options = Array.isArray(block.options) - ? block.options - .map((option) => normalizeInteractiveOption(option)) - .filter((option): option is InteractiveReplyOption => Boolean(option)) - : []; - return options.length > 0 - ? ({ - type: "select", - placeholder: readTrimmedString(block.placeholder), - options, - } as const) - : undefined; - } - return undefined; - }) - .filter((entry): entry is InteractiveReplyBlock => Boolean(entry)) - : []; - return blocks.length > 0 ? { blocks } : undefined; -} - -function readStringParam( - params: Record, - key: string, - options: { required?: boolean; trim?: boolean; label?: string; allowEmpty?: boolean } = {}, -): string | undefined { - const { required = false, trim = true, label = key, allowEmpty = false } = options; - const raw = params[key]; - if (typeof raw !== "string") { - if (required) { - throw new Error(`${label} required`); - } - return undefined; - } - const value = trim ? raw.trim() : raw; - if (!value && !allowEmpty) { - if (required) { - throw new Error(`${label} required`); - } - return undefined; - } - return value; -} - -function readNumberParam( - params: Record, - key: string, - options: { required?: boolean; label?: string; integer?: boolean; strict?: boolean } = {}, -): number | undefined { - const { required = false, label = key, integer = false, strict = false } = options; - const raw = params[key]; - let value: number | undefined; - if (typeof raw === "number" && Number.isFinite(raw)) { - value = raw; - } else if (typeof raw === "string") { - const trimmed = raw.trim(); - if (trimmed) { - const parsed = strict ? Number(trimmed) : Number.parseFloat(trimmed); - if (Number.isFinite(parsed)) { - value = parsed; - } - } - } - if (value === undefined) { - if (required) { - throw new Error(`${label} required`); - } - return undefined; - } - return integer ? Math.trunc(value) : value; -} - -function readSlackBlocksParam(actionParams: Record) { - return parseSlackBlocksInput(actionParams.blocks) as Record[] | undefined; -} - -export async function handleSlackMessageAction(params: { - providerId: string; - ctx: ChannelMessageActionContext; - invoke: SlackActionInvoke; - normalizeChannelId?: (channelId: string) => string; - includeReadThreadId?: boolean; -}): Promise> { - const { providerId, ctx, invoke, normalizeChannelId, includeReadThreadId = false } = params; - const { action, cfg, params: actionParams } = ctx; - const accountId = ctx.accountId ?? undefined; - const resolveChannelId = () => { - const channelId = - readStringParam(actionParams, "channelId") ?? - readStringParam(actionParams, "to", { required: true }); - if (!channelId) { - throw new Error("channelId required"); - } - return normalizeChannelId ? normalizeChannelId(channelId) : channelId; - }; - - if (action === "send") { - const to = readStringParam(actionParams, "to", { required: true }); - const content = readStringParam(actionParams, "message", { allowEmpty: true }); - const mediaUrl = readStringParam(actionParams, "media", { trim: false }); - const interactive = normalizeInteractiveReply(actionParams.interactive); - const interactiveBlocks = interactive ? buildSlackInteractiveBlocks(interactive) : undefined; - const blocks = readSlackBlocksParam(actionParams) ?? interactiveBlocks; - if (!content && !mediaUrl && !blocks) { - throw new Error("Slack send requires message, blocks, or media."); - } - if (mediaUrl && blocks) { - throw new Error("Slack send does not support blocks with media."); - } - const threadId = readStringParam(actionParams, "threadId"); - const replyTo = readStringParam(actionParams, "replyTo"); - return await invoke( - { - action: "sendMessage", - to, - content: content ?? "", - mediaUrl: mediaUrl ?? undefined, - accountId, - threadTs: threadId ?? replyTo ?? undefined, - ...(blocks ? { blocks } : {}), - }, - cfg, - ctx.toolContext, - ); - } - - if (action === "react") { - const messageId = readStringParam(actionParams, "messageId", { required: true }); - const emoji = readStringParam(actionParams, "emoji", { allowEmpty: true }); - const remove = typeof actionParams.remove === "boolean" ? actionParams.remove : undefined; - return await invoke( - { action: "react", channelId: resolveChannelId(), messageId, emoji, remove, accountId }, - cfg, - ); - } - - if (action === "reactions") { - const messageId = readStringParam(actionParams, "messageId", { required: true }); - const limit = readNumberParam(actionParams, "limit", { integer: true }); - return await invoke( - { action: "reactions", channelId: resolveChannelId(), messageId, limit, accountId }, - cfg, - ); - } - - if (action === "read") { - const limit = readNumberParam(actionParams, "limit", { integer: true }); - const readAction: Record = { - action: "readMessages", - channelId: resolveChannelId(), - limit, - before: readStringParam(actionParams, "before"), - after: readStringParam(actionParams, "after"), - accountId, - }; - if (includeReadThreadId) { - readAction.threadId = readStringParam(actionParams, "threadId"); - } - return await invoke(readAction, cfg); - } - - if (action === "edit") { - const messageId = readStringParam(actionParams, "messageId", { required: true }); - const content = readStringParam(actionParams, "message", { allowEmpty: true }); - const blocks = readSlackBlocksParam(actionParams); - if (!content && !blocks) { - throw new Error("Slack edit requires message or blocks."); - } - return await invoke( - { - action: "editMessage", - channelId: resolveChannelId(), - messageId, - content: content ?? "", - blocks, - accountId, - }, - cfg, - ); - } - - if (action === "delete") { - const messageId = readStringParam(actionParams, "messageId", { required: true }); - return await invoke( - { action: "deleteMessage", channelId: resolveChannelId(), messageId, accountId }, - cfg, - ); - } - - if (action === "pin" || action === "unpin" || action === "list-pins") { - const messageId = - action === "list-pins" - ? undefined - : readStringParam(actionParams, "messageId", { required: true }); - return await invoke( - { - action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", - channelId: resolveChannelId(), - messageId, - accountId, - }, - cfg, - ); - } - - if (action === "member-info") { - const userId = readStringParam(actionParams, "userId", { required: true }); - return await invoke({ action: "memberInfo", userId, accountId }, cfg); - } - - if (action === "emoji-list") { - const limit = readNumberParam(actionParams, "limit", { integer: true }); - return await invoke({ action: "emojiList", limit, accountId }, cfg); - } - - if (action === "download-file") { - const fileId = readStringParam(actionParams, "fileId", { required: true }); - const channelId = - readStringParam(actionParams, "channelId") ?? readStringParam(actionParams, "to"); - const threadId = - readStringParam(actionParams, "threadId") ?? readStringParam(actionParams, "replyTo"); - return await invoke( - { - action: "downloadFile", - fileId, - channelId: channelId ?? undefined, - threadId: threadId ?? undefined, - accountId, - }, - cfg, - ); - } - - throw new Error(`Action ${action} is not supported for provider ${providerId}.`); -} +export { handleSlackMessageAction } from "../../../src/plugin-sdk/slack-message-actions.js"; 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/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 6b32f206d2e..a0f068b3e81 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,8 +1,11 @@ import { applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + hasConfiguredSecretInput, migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import { + normalizeAccountId, + type OpenClawConfig, noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, @@ -10,105 +13,21 @@ import { setAccountGroupPolicyForChannel, setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import type { - ChannelSetupWizard, - ChannelSetupWizardAllowFromEntry, -} from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +} from "../../../src/plugin-sdk-internal/setup.js"; +import { + type ChannelSetupAdapter, + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type ChannelSetupWizardAllowFromEntry, +} from "../../../src/plugin-sdk-internal/setup.js"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; - -const channel = "slack" as const; - -function buildSlackManifest(botName: string) { - const safeName = botName.trim() || "OpenClaw"; - const manifest = { - display_information: { - name: safeName, - description: `${safeName} connector for OpenClaw`, - }, - features: { - bot_user: { - display_name: safeName, - always_online: false, - }, - app_home: { - messages_tab_enabled: true, - messages_tab_read_only_enabled: false, - }, - slash_commands: [ - { - command: "/openclaw", - description: "Send a message to OpenClaw", - should_escape: false, - }, - ], - }, - oauth_config: { - scopes: { - bot: [ - "chat:write", - "channels:history", - "channels:read", - "groups:history", - "im:history", - "mpim:history", - "users:read", - "app_mentions:read", - "reactions:read", - "reactions:write", - "pins:read", - "pins:write", - "emoji:read", - "commands", - "files:read", - "files:write", - ], - }, - }, - settings: { - socket_mode_enabled: true, - event_subscriptions: { - bot_events: [ - "app_mention", - "message.channels", - "message.groups", - "message.im", - "message.mpim", - "reaction_added", - "reaction_removed", - "member_joined_channel", - "member_left_channel", - "channel_rename", - "pin_added", - "pin_removed", - ], - }, - }, - }; - return JSON.stringify(manifest, null, 2); -} - -function buildSlackSetupLines(botName = "OpenClaw"): string[] { - return [ - "1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)", - "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", - "3) Install App to workspace to get the xoxb- bot token", - "4) Enable Event Subscriptions (socket) for message events", - "5) App Home -> enable the Messages tab for DMs", - "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", - `Docs: ${formatDocsLink("/slack", "slack")}`, - "", - "Manifest (JSON):", - buildSlackManifest(botName), - ]; -} +import { + buildSlackSetupLines, + isSlackSetupAccountConfigured, + setSlackChannelAllowlist, + SLACK_CHANNEL as channel, +} from "./shared.js"; function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig { return patchChannelConfigForAccount({ @@ -119,28 +38,6 @@ function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawCon }); } -function setSlackChannelAllowlist( - cfg: OpenClawConfig, - accountId: string, - channelKeys: string[], -): OpenClawConfig { - const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); - return patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { channels }, - }); -} - -function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { - const hasConfiguredBotToken = - Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); - const hasConfiguredAppToken = - Boolean(account.appToken?.trim()) || hasConfiguredSecretInput(account.config.appToken); - return hasConfiguredBotToken && hasConfiguredAppToken; -} - export const slackSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => @@ -257,7 +154,7 @@ export function createSlackSetupWizardProxy( title: "Slack socket mode tokens", lines: buildSlackSetupLines(), shouldShow: ({ cfg, accountId }) => - !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), }, envShortcut: { prompt: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", @@ -266,7 +163,7 @@ export function createSlackSetupWizardProxy( accountId === DEFAULT_ACCOUNT_ID && Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && Boolean(process.env.SLACK_APP_TOKEN?.trim()) && - !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), }, credentials: [ diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index 5769c4c6d77..de7dc06e40e 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,6 +1,11 @@ import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, + hasConfiguredSecretInput, noteChannelLookupFailure, noteChannelLookupSummary, + normalizeAccountId, + type OpenClawConfig, parseMentionOrPrefixedId, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, @@ -8,17 +13,13 @@ import { setAccountGroupPolicyForChannel, setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; + type WizardPrompter, +} from "../../../src/plugin-sdk-internal/setup.js"; import type { + ChannelSetupDmPolicy, ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, -} from "../../../src/channels/plugins/setup-wizard.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +} from "../../../src/plugin-sdk-internal/setup.js"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, @@ -29,106 +30,12 @@ import { import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { slackSetupAdapter } from "./setup-core.js"; - -const channel = "slack" as const; - -function buildSlackManifest(botName: string) { - const safeName = botName.trim() || "OpenClaw"; - const manifest = { - display_information: { - name: safeName, - description: `${safeName} connector for OpenClaw`, - }, - features: { - bot_user: { - display_name: safeName, - always_online: false, - }, - app_home: { - messages_tab_enabled: true, - messages_tab_read_only_enabled: false, - }, - slash_commands: [ - { - command: "/openclaw", - description: "Send a message to OpenClaw", - should_escape: false, - }, - ], - }, - oauth_config: { - scopes: { - bot: [ - "chat:write", - "channels:history", - "channels:read", - "groups:history", - "im:history", - "mpim:history", - "users:read", - "app_mentions:read", - "reactions:read", - "reactions:write", - "pins:read", - "pins:write", - "emoji:read", - "commands", - "files:read", - "files:write", - ], - }, - }, - settings: { - socket_mode_enabled: true, - event_subscriptions: { - bot_events: [ - "app_mention", - "message.channels", - "message.groups", - "message.im", - "message.mpim", - "reaction_added", - "reaction_removed", - "member_joined_channel", - "member_left_channel", - "channel_rename", - "pin_added", - "pin_removed", - ], - }, - }, - }; - return JSON.stringify(manifest, null, 2); -} - -function buildSlackSetupLines(botName = "OpenClaw"): string[] { - return [ - "1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)", - "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", - "3) Install App to workspace to get the xoxb- bot token", - "4) Enable Event Subscriptions (socket) for message events", - "5) App Home -> enable the Messages tab for DMs", - "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", - `Docs: ${formatDocsLink("/slack", "slack")}`, - "", - "Manifest (JSON):", - buildSlackManifest(botName), - ]; -} - -function setSlackChannelAllowlist( - cfg: OpenClawConfig, - accountId: string, - channelKeys: string[], -): OpenClawConfig { - const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); - return patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { channels }, - }); -} +import { + buildSlackSetupLines, + isSlackSetupAccountConfigured, + setSlackChannelAllowlist, + SLACK_CHANNEL as channel, +} from "./shared.js"; function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig { return patchChannelConfigForAccount({ @@ -226,14 +133,6 @@ const slackDmPolicy: ChannelSetupDmPolicy = { promptAllowFrom: promptSlackAllowFrom, }; -function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { - const hasConfiguredBotToken = - Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); - const hasConfiguredAppToken = - Boolean(account.appToken?.trim()) || hasConfiguredSecretInput(account.config.appToken); - return hasConfiguredBotToken && hasConfiguredAppToken; -} - export const slackSetupWizard: ChannelSetupWizard = { channel, status: { @@ -253,7 +152,7 @@ export const slackSetupWizard: ChannelSetupWizard = { title: "Slack socket mode tokens", lines: buildSlackSetupLines(), shouldShow: ({ cfg, accountId }) => - !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), }, envShortcut: { prompt: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", @@ -262,7 +161,7 @@ export const slackSetupWizard: ChannelSetupWizard = { accountId === DEFAULT_ACCOUNT_ID && Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && Boolean(process.env.SLACK_APP_TOKEN?.trim()) && - !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), }, credentials: [ diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts new file mode 100644 index 00000000000..7345de3a22c --- /dev/null +++ b/extensions/slack/src/shared.ts @@ -0,0 +1,152 @@ +import { patchChannelConfigForAccount } from "../../../src/channels/plugins/setup-wizard-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { formatAllowFromLowercase } from "../../../src/plugin-sdk/allow-from.js"; +import { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, +} from "../../../src/plugin-sdk/channel-config-helpers.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { inspectSlackAccount } from "./account-inspect.js"; +import { + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + type ResolvedSlackAccount, +} from "./accounts.js"; + +export const SLACK_CHANNEL = "slack" as const; + +function buildSlackManifest(botName: string) { + const safeName = botName.trim() || "OpenClaw"; + const manifest = { + display_information: { + name: safeName, + description: `${safeName} connector for OpenClaw`, + }, + features: { + bot_user: { + display_name: safeName, + always_online: false, + }, + app_home: { + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }, + slash_commands: [ + { + command: "/openclaw", + description: "Send a message to OpenClaw", + should_escape: false, + }, + ], + }, + oauth_config: { + scopes: { + bot: [ + "chat:write", + "channels:history", + "channels:read", + "groups:history", + "im:history", + "mpim:history", + "users:read", + "app_mentions:read", + "reactions:read", + "reactions:write", + "pins:read", + "pins:write", + "emoji:read", + "commands", + "files:read", + "files:write", + ], + }, + }, + settings: { + socket_mode_enabled: true, + event_subscriptions: { + bot_events: [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + "reaction_added", + "reaction_removed", + "member_joined_channel", + "member_left_channel", + "channel_rename", + "pin_added", + "pin_removed", + ], + }, + }, + }; + return JSON.stringify(manifest, null, 2); +} + +export function buildSlackSetupLines(botName = "OpenClaw"): string[] { + return [ + "1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)", + "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", + "3) Install App to workspace to get the xoxb- bot token", + "4) Enable Event Subscriptions (socket) for message events", + "5) App Home -> enable the Messages tab for DMs", + "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", + `Docs: ${formatDocsLink("/slack", "slack")}`, + "", + "Manifest (JSON):", + buildSlackManifest(botName), + ]; +} + +export function setSlackChannelAllowlist( + cfg: OpenClawConfig, + accountId: string, + channelKeys: string[], +): OpenClawConfig { + const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); + return patchChannelConfigForAccount({ + cfg, + channel: SLACK_CHANNEL, + accountId, + patch: { channels }, + }); +} + +export function isSlackPluginAccountConfigured(account: ResolvedSlackAccount): boolean { + const mode = account.config.mode ?? "socket"; + const hasBotToken = Boolean(account.botToken?.trim()); + if (!hasBotToken) { + return false; + } + if (mode === "http") { + return Boolean(account.config.signingSecret?.trim()); + } + return Boolean(account.appToken?.trim()); +} + +export function isSlackSetupAccountConfigured(account: ResolvedSlackAccount): boolean { + const hasConfiguredBotToken = + Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); + const hasConfiguredAppToken = + Boolean(account.appToken?.trim()) || hasConfiguredSecretInput(account.config.appToken); + return hasConfiguredBotToken && hasConfiguredAppToken; +} + +export const slackConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, +}); + +export const slackConfigBase = createScopedChannelConfigBase({ + sectionKey: SLACK_CHANNEL, + listAccountIds: listSlackAccountIds, + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultSlackAccountId, + clearBaseFields: ["botToken", "appToken", "name"], +}); 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/synthetic/index.ts b/extensions/synthetic/index.ts index 080245606da..6e0d6072bf1 100644 --- a/extensions/synthetic/index.ts +++ b/extensions/synthetic/index.ts @@ -1,10 +1,10 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildSyntheticProvider } from "../../src/agents/models-config.providers.static.js"; import { applySyntheticConfig, SYNTHETIC_DEFAULT_MODEL_REF, } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildSyntheticProvider } from "./provider-catalog.js"; const PROVIDER_ID = "synthetic"; diff --git a/extensions/synthetic/provider-catalog.ts b/extensions/synthetic/provider-catalog.ts new file mode 100644 index 00000000000..181affdde2b --- /dev/null +++ b/extensions/synthetic/provider-catalog.ts @@ -0,0 +1,14 @@ +import { + buildSyntheticModelDefinition, + SYNTHETIC_BASE_URL, + SYNTHETIC_MODEL_CATALOG, +} from "../../src/agents/synthetic-models.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +export function buildSyntheticProvider(): ModelProviderConfig { + return { + baseUrl: SYNTHETIC_BASE_URL, + api: "anthropic-messages", + models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition), + }; +} 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/accounts.test.ts b/extensions/telegram/src/accounts.test.ts index 28af65a5d8a..fb83b9071a5 100644 --- a/extensions/telegram/src/accounts.test.ts +++ b/extensions/telegram/src/accounts.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; +import * as subsystemModule from "../../../src/logging/subsystem.js"; import { withEnv } from "../../../src/test-utils/env.js"; import { listTelegramAccountIds, @@ -29,15 +30,16 @@ function resolveAccountWithEnv( return withEnv(env, () => resolveTelegramAccount({ cfg, ...(accountId ? { accountId } : {}) })); } -vi.mock("../../../src/logging/subsystem.js", () => ({ - createSubsystemLogger: () => { +beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(subsystemModule, "createSubsystemLogger").mockImplementation(() => { const logger = { warn: warnMock, child: () => logger, }; - return logger; - }, -})); + return logger as unknown as ReturnType; + }); +}); describe("resolveTelegramAccount", () => { afterEach(() => { diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts index cff6853a5b1..ab94be5845c 100644 --- a/extensions/telegram/src/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -21,7 +21,14 @@ import { } from "../../../src/routing/session-key.js"; import { resolveTelegramToken } from "./token.js"; -const log = createSubsystemLogger("telegram/accounts"); +let log: ReturnType | null = null; + +function getLog() { + if (!log) { + log = createSubsystemLogger("telegram/accounts"); + } + return log; +} function formatDebugArg(value: unknown): string { if (typeof value === "string") { @@ -36,7 +43,7 @@ function formatDebugArg(value: unknown): string { const debugAccounts = (...args: unknown[]) => { if (isTruthyEnvValue(process.env.OPENCLAW_DEBUG_TELEGRAM_ACCOUNTS)) { const parts = args.map((arg) => formatDebugArg(arg)); - log.warn(parts.join(" ").trim()); + getLog().warn(parts.join(" ").trim()); } }; @@ -92,7 +99,7 @@ export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { } if (ids.length > 1 && !emittedMissingDefaultWarn) { emittedMissingDefaultWarn = true; - log.warn( + getLog().warn( `channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` + `${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`, ); 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.registry.test.ts b/extensions/telegram/src/bot-native-commands.registry.test.ts new file mode 100644 index 00000000000..d264a059505 --- /dev/null +++ b/extensions/telegram/src/bot-native-commands.registry.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; +import { clearPluginCommands, registerPluginCommand } from "../../../src/plugins/commands.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import { registerTelegramNativeCommands } from "./bot-native-commands.js"; + +const { listSkillCommandsForAgents } = vi.hoisted(() => ({ + listSkillCommandsForAgents: vi.fn(() => []), +})); +const deliveryMocks = vi.hoisted(() => ({ + deliverReplies: vi.fn(async () => ({ delivered: true })), +})); + +vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listSkillCommandsForAgents, + }; +}); + +vi.mock("./bot/delivery.js", () => ({ + deliverReplies: deliveryMocks.deliverReplies, +})); + +describe("registerTelegramNativeCommands real plugin registry", () => { + type RegisteredCommand = { + command: string; + description: string; + }; + + function createCommandBot() { + const commandHandlers = new Map Promise>(); + const sendMessage = vi.fn().mockResolvedValue(undefined); + const setMyCommands = vi.fn().mockResolvedValue(undefined); + const bot = { + api: { + setMyCommands, + sendMessage, + }, + command: vi.fn((name: string, cb: (ctx: unknown) => Promise) => { + commandHandlers.set(name, cb); + }), + } as unknown as Parameters[0]["bot"]; + return { bot, commandHandlers, sendMessage, setMyCommands }; + } + + async function waitForRegisteredCommands( + setMyCommands: ReturnType, + ): Promise { + await vi.waitFor(() => { + expect(setMyCommands).toHaveBeenCalled(); + }); + return setMyCommands.mock.calls[0]?.[0] as RegisteredCommand[]; + } + + const buildParams = (cfg: OpenClawConfig, accountId = "default") => + ({ + bot: { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn(), + } as unknown as Parameters[0]["bot"], + cfg, + runtime: {} as RuntimeEnv, + accountId, + telegramCfg: {} as TelegramAccountConfig, + allowFrom: [], + groupAllowFrom: [], + replyToMode: "off", + textLimit: 4000, + useAccessGroups: false, + nativeEnabled: true, + nativeSkillsEnabled: true, + nativeDisabledExplicit: false, + resolveGroupPolicy: () => + ({ + allowlistEnabled: false, + allowed: true, + }) as ReturnType< + Parameters[0]["resolveGroupPolicy"] + >, + resolveTelegramGroupConfig: () => ({ + groupConfig: undefined, + topicConfig: undefined, + }), + shouldSkipUpdate: () => false, + opts: { token: "token" }, + }) satisfies Parameters[0]; + + beforeEach(() => { + clearPluginCommands(); + deliveryMocks.deliverReplies.mockClear(); + deliveryMocks.deliverReplies.mockResolvedValue({ delivered: true }); + listSkillCommandsForAgents.mockClear(); + listSkillCommandsForAgents.mockReturnValue([]); + }); + + afterEach(() => { + clearPluginCommands(); + }); + + it("registers and executes plugin commands through the real plugin registry", async () => { + const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot(); + + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + description: "Pair device", + acceptsArgs: true, + requireAuth: false, + handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), + }), + ).toEqual({ ok: true }); + + registerTelegramNativeCommands({ + ...buildParams({}), + bot, + }); + + const registeredCommands = await waitForRegisteredCommands(setMyCommands); + expect(registeredCommands).toEqual( + expect.arrayContaining([{ command: "pair", description: "Pair device" }]), + ); + + const handler = commandHandlers.get("pair"); + expect(handler).toBeTruthy(); + + await handler?.({ + match: "now", + 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({ + replies: [expect.objectContaining({ text: "paired:now" })], + }), + ); + expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); + }); + + it("keeps real plugin command handlers available when native menu registration is disabled", () => { + const { bot, commandHandlers, setMyCommands } = createCommandBot(); + + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + description: "Pair device", + acceptsArgs: true, + requireAuth: false, + handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), + }), + ).toEqual({ ok: true }); + + registerTelegramNativeCommands({ + ...buildParams({}, "default"), + bot, + nativeEnabled: false, + }); + + expect(setMyCommands).not.toHaveBeenCalled(); + expect(commandHandlers.has("pair")).toBe(true); + }); + + it("allows requireAuth:false plugin commands for unauthorized senders through the real registry", async () => { + const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot(); + + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + description: "Pair device", + acceptsArgs: true, + requireAuth: false, + handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), + }), + ).toEqual({ ok: true }); + + registerTelegramNativeCommands({ + ...buildParams({ + commands: { allowFrom: { telegram: ["999"] } } as OpenClawConfig["commands"], + }), + bot, + allowFrom: ["999"], + nativeEnabled: false, + }); + + expect(setMyCommands).not.toHaveBeenCalled(); + + const handler = commandHandlers.get("pair"); + expect(handler).toBeTruthy(); + + await handler?.({ + match: "now", + message: { + message_id: 10, + date: 123456, + chat: { id: 123, type: "private" }, + from: { id: 111, username: "nope" }, + }, + }); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ text: "paired:now" })], + }), + ); + expect(sendMessage).not.toHaveBeenCalled(); + }); +}); 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/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/bot/helpers.test.ts b/extensions/telegram/src/bot/helpers.test.ts index fe30465b40c..5777216f2ac 100644 --- a/extensions/telegram/src/bot/helpers.test.ts +++ b/extensions/telegram/src/bot/helpers.test.ts @@ -1,3 +1,4 @@ +import type { Message } from "grammy/types"; import { describe, expect, it } from "vitest"; import { buildTelegramThreadParams, @@ -404,8 +405,59 @@ describe("hasBotMention", () => { ), ).toBe(true); }); -}); + it("matches mention followed by punctuation", () => { + expect( + hasBotMention( + { + text: "@gaian, what's up?", + chat: { id: 1, type: "supergroup" }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + "gaian", + ), + ).toBe(true); + }); + + it("matches mention followed by space", () => { + expect( + hasBotMention( + { + text: "@gaian how are you", + chat: { id: 1, type: "supergroup" }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + "gaian", + ), + ).toBe(true); + }); + + it("does not match substring of a longer username", () => { + expect( + hasBotMention( + { + text: "@gaianchat_bot hello", + chat: { id: 1, type: "supergroup" }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + "gaian", + ), + ).toBe(false); + }); + + it("does not match when mention is a prefix of another word", () => { + expect( + hasBotMention( + { + text: "@gaianbot do something", + chat: { id: 1, type: "supergroup" }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + "gaian", + ), + ).toBe(false); + }); +}); describe("expandTextLinks", () => { it("returns text unchanged when no entities are provided", () => { expect(expandTextLinks("Hello world")).toBe("Hello world"); diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index f28a96afff7..8cc6b39fc19 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -3,12 +3,12 @@ import { createScopedAccountConfigAccessors, formatAllowFromLowercase, } from "openclaw/plugin-sdk/compat"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, getChatChannelMeta, normalizeAccountId, TelegramConfigSchema, - type ChannelPlugin, type OpenClawConfig, } from "openclaw/plugin-sdk/telegram"; import { inspectTelegramAccount } from "./account-inspect.js"; 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..6fcc12552c8 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -12,6 +12,7 @@ import { resolveThreadSessionKeys, type RoutePeer, } from "openclaw/plugin-sdk/core"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, buildTokenChannelStatusSummary, @@ -28,7 +29,6 @@ import { resolveTelegramGroupToolPolicy, TelegramConfigSchema, type ChannelMessageActionAdapter, - type ChannelPlugin, type OpenClawConfig, } from "openclaw/plugin-sdk/telegram"; import { parseTelegramTopicConversation } from "../../../src/acp/conversation-id.js"; @@ -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("Telegram runtime not initialized"); diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index dedf2ca8527..6ef275ee8b2 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,16 +1,20 @@ import { applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + formatCliCommand, + formatDocsLink, migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import { + normalizeAccountId, patchChannelConfigForAccount, + promptResolvedAllowFrom, splitSetupEntries, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; + type OpenClawConfig, + type WizardPrompter, +} from "../../../src/plugin-sdk-internal/setup.js"; +import type { + ChannelSetupAdapter, + ChannelSetupDmPolicy, +} from "../../../src/plugin-sdk-internal/setup.js"; import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; import { fetchTelegramChatId } from "./api-fetch.js"; @@ -71,11 +75,7 @@ export async function resolveTelegramAllowFromEntries(params: { export async function promptTelegramAllowFromForAccount(params: { cfg: OpenClawConfig; - prompter: Parameters< - NonNullable< - import("../../../src/channels/plugins/setup-wizard-types.js").ChannelSetupDmPolicy["promptAllowFrom"] - > - >[0]["prompter"]; + prompter: WizardPrompter; accountId?: string; }) { const accountId = params.accountId ?? resolveDefaultTelegramAccountId(params.cfg); @@ -87,8 +87,6 @@ export async function promptTelegramAllowFromForAccount(params: { "Telegram", ); } - const { promptResolvedAllowFrom } = - await import("../../../src/channels/plugins/setup-wizard-helpers.runtime.js"); const unique = await promptResolvedAllowFrom({ prompter: params.prompter, existing: resolved.config.allowFrom ?? [], diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index d0f122af174..7d95f40728b 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -1,14 +1,16 @@ import { + DEFAULT_ACCOUNT_ID, + hasConfiguredSecretInput, + type OpenClawConfig, patchChannelConfigForAccount, setChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, splitSetupEntries, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import { type ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +} from "../../../src/plugin-sdk-internal/setup.js"; +import type { + ChannelSetupDmPolicy, + ChannelSetupWizard, +} from "../../../src/plugin-sdk-internal/setup.js"; import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; import { diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts index 827b4899e21..e0009d6b76a 100644 --- a/extensions/telegram/src/token.ts +++ b/extensions/telegram/src/token.ts @@ -1,7 +1,7 @@ -import type { BaseTokenResolution } from "../../../src/channels/plugins/types.js"; +import type { TelegramAccountConfig } from "openclaw/plugin-sdk/telegram"; +import type { BaseTokenResolution } from "../../../src/channels/plugins/types.core.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { normalizeResolvedSecretInputString } from "../../../src/config/types.secrets.js"; -import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js"; import { tryReadSecretFileSync } from "../../../src/infra/secret-file.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; diff --git a/extensions/test-utils/plugin-api.ts b/extensions/test-utils/plugin-api.ts index 5c621700602..281e151aeb7 100644 --- a/extensions/test-utils/plugin-api.ts +++ b/extensions/test-utils/plugin-api.ts @@ -15,6 +15,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi registerCli() {}, registerService() {}, registerProvider() {}, + registerSpeechProvider() {}, registerWebSearchProvider() {}, registerInteractiveHandler() {}, registerCommand() {}, diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/extensions/test-utils/plugin-runtime-mock.ts index 81e3fdedeec..19a17e0811a 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/extensions/test-utils/plugin-runtime-mock.ts @@ -1,6 +1,7 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/test-utils"; import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk/test-utils"; import { vi } from "vitest"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../src/agents/defaults.js"; type DeepPartial = { [K in keyof T]?: T[K] extends (...args: never[]) => unknown @@ -39,6 +40,50 @@ export function createPluginRuntimeMock(overrides: DeepPartial = loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"], writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"], }, + agent: { + defaults: { + model: DEFAULT_MODEL, + provider: DEFAULT_PROVIDER, + }, + resolveAgentDir: vi.fn( + () => "/tmp/agent", + ) as unknown as PluginRuntime["agent"]["resolveAgentDir"], + resolveAgentWorkspaceDir: vi.fn( + () => "/tmp/workspace", + ) as unknown as PluginRuntime["agent"]["resolveAgentWorkspaceDir"], + resolveAgentIdentity: vi.fn(() => ({ + name: "test-agent", + })) as unknown as PluginRuntime["agent"]["resolveAgentIdentity"], + resolveThinkingDefault: vi.fn( + () => "off", + ) as unknown as PluginRuntime["agent"]["resolveThinkingDefault"], + runEmbeddedPiAgent: vi.fn().mockResolvedValue({ + payloads: [], + meta: {}, + }) as unknown as PluginRuntime["agent"]["runEmbeddedPiAgent"], + resolveAgentTimeoutMs: vi.fn( + () => 30_000, + ) as unknown as PluginRuntime["agent"]["resolveAgentTimeoutMs"], + ensureAgentWorkspace: vi + .fn() + .mockResolvedValue(undefined) as unknown as PluginRuntime["agent"]["ensureAgentWorkspace"], + session: { + resolveStorePath: vi.fn( + () => "/tmp/agent-sessions.json", + ) as unknown as PluginRuntime["agent"]["session"]["resolveStorePath"], + loadSessionStore: vi.fn( + () => ({}), + ) as unknown as PluginRuntime["agent"]["session"]["loadSessionStore"], + saveSessionStore: vi + .fn() + .mockResolvedValue( + undefined, + ) as unknown as PluginRuntime["agent"]["session"]["saveSessionStore"], + resolveSessionFilePath: vi.fn( + (sessionId: string) => `/tmp/${sessionId}.json`, + ) as unknown as PluginRuntime["agent"]["session"]["resolveSessionFilePath"], + }, + }, system: { enqueueSystemEvent: vi.fn() as unknown as PluginRuntime["system"]["enqueueSystemEvent"], requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"], diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts new file mode 100644 index 00000000000..f9f11e4620c --- /dev/null +++ b/extensions/tlon/src/channel.runtime.ts @@ -0,0 +1,249 @@ +import crypto from "node:crypto"; +import { configureClient } from "@tloncorp/api"; +import type { + ChannelOutboundAdapter, + ChannelPlugin, + OpenClawConfig, +} from "openclaw/plugin-sdk/tlon"; +import { monitorTlonProvider } from "./monitor/index.js"; +import { tlonSetupWizard } from "./setup-surface.js"; +import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; +import { resolveTlonAccount } from "./types.js"; +import { authenticate } from "./urbit/auth.js"; +import { ssrfPolicyFromAllowPrivateNetwork } from "./urbit/context.js"; +import { urbitFetch } from "./urbit/fetch.js"; +import { + buildMediaStory, + sendDm, + sendDmWithStory, + sendGroupMessage, + sendGroupMessageWithStory, +} from "./urbit/send.js"; +import { uploadImageFromUrl } from "./urbit/upload.js"; + +type ResolvedTlonAccount = ReturnType; +type ConfiguredTlonAccount = ResolvedTlonAccount & { + ship: string; + url: string; + code: string; +}; + +async function createHttpPokeApi(params: { + url: string; + code: string; + ship: string; + allowPrivateNetwork?: boolean; +}) { + const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(params.allowPrivateNetwork); + const cookie = await authenticate(params.url, params.code, { ssrfPolicy }); + const channelId = `${Math.floor(Date.now() / 1000)}-${crypto.randomUUID()}`; + const channelPath = `/~/channel/${channelId}`; + const shipName = params.ship.replace(/^~/, ""); + + return { + poke: async (pokeParams: { app: string; mark: string; json: unknown }) => { + const pokeId = Date.now(); + const pokeData = { + id: pokeId, + action: "poke", + ship: shipName, + app: pokeParams.app, + mark: pokeParams.mark, + json: pokeParams.json, + }; + + const { response, release } = await urbitFetch({ + baseUrl: params.url, + path: channelPath, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: cookie.split(";")[0], + }, + body: JSON.stringify([pokeData]), + }, + ssrfPolicy, + auditContext: "tlon-poke", + }); + + try { + if (!response.ok && response.status !== 204) { + const errorText = await response.text(); + throw new Error(`Poke failed: ${response.status} - ${errorText}`); + } + + return pokeId; + } finally { + await release(); + } + }, + delete: async () => { + // No-op for HTTP-only client + }, + }; +} + +function resolveOutboundContext(params: { + cfg: OpenClawConfig; + accountId?: string | null; + to: string; +}) { + const account = resolveTlonAccount(params.cfg, params.accountId ?? undefined); + if (!account.configured || !account.ship || !account.url || !account.code) { + throw new Error("Tlon account not configured"); + } + + const parsed = parseTlonTarget(params.to); + if (!parsed) { + throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); + } + + return { account: account as ConfiguredTlonAccount, parsed }; +} + +function resolveReplyId(replyToId?: string | null, threadId?: string | number | null) { + return (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined; +} + +async function withHttpPokeAccountApi( + account: ConfiguredTlonAccount, + run: (api: Awaited>) => Promise, +) { + const api = await createHttpPokeApi({ + url: account.url, + ship: account.ship, + code: account.code, + allowPrivateNetwork: account.allowPrivateNetwork ?? undefined, + }); + + try { + return await run(api); + } finally { + try { + await api.delete(); + } catch { + // ignore cleanup errors + } + } +} + +export const tlonRuntimeOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + textChunkLimit: 10000, + resolveTarget: ({ to }) => { + const parsed = parseTlonTarget(to ?? ""); + if (!parsed) { + return { + ok: false, + error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`), + }; + } + if (parsed.kind === "dm") { + return { ok: true, to: parsed.ship }; + } + return { ok: true, to: parsed.nest }; + }, + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { + const { account, parsed } = resolveOutboundContext({ cfg, accountId, to }); + return withHttpPokeAccountApi(account, async (api) => { + const fromShip = normalizeShip(account.ship); + if (parsed.kind === "dm") { + return await sendDm({ + api, + fromShip, + toShip: parsed.ship, + text, + }); + } + return await sendGroupMessage({ + api, + fromShip, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + text, + replyToId: resolveReplyId(replyToId, threadId), + }); + }); + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => { + const { account, parsed } = resolveOutboundContext({ cfg, accountId, to }); + + configureClient({ + shipUrl: account.url, + shipName: account.ship.replace(/^~/, ""), + verbose: false, + getCode: async () => account.code, + }); + + const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined; + return withHttpPokeAccountApi(account, async (api) => { + const fromShip = normalizeShip(account.ship); + const story = buildMediaStory(text, uploadedUrl); + + if (parsed.kind === "dm") { + return await sendDmWithStory({ + api, + fromShip, + toShip: parsed.ship, + story, + }); + } + return await sendGroupMessageWithStory({ + api, + fromShip, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + story, + replyToId: resolveReplyId(replyToId, threadId), + }); + }); + }, +}; + +export async function probeTlonAccount(account: ConfiguredTlonAccount) { + try { + const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork); + const cookie = await authenticate(account.url, account.code, { ssrfPolicy }); + const { response, release } = await urbitFetch({ + baseUrl: account.url, + path: "/~/name", + init: { + method: "GET", + headers: { Cookie: cookie }, + }, + ssrfPolicy, + timeoutMs: 30_000, + auditContext: "tlon-probe-account", + }); + try { + if (!response.ok) { + return { ok: false, error: `Name request failed: ${response.status}` }; + } + return { ok: true }; + } finally { + await release(); + } + } catch (error) { + return { ok: false, error: (error as { message?: string })?.message ?? String(error) }; + } +} + +export async function startTlonGatewayAccount( + ctx: Parameters["startAccount"]>>[0], +) { + const account = ctx.account; + ctx.setStatus({ + accountId: account.accountId, + ship: account.ship, + url: account.url, + } as import("openclaw/plugin-sdk/tlon").ChannelAccountSnapshot); + ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`); + return monitorTlonProvider({ + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + accountId: account.accountId, + }); +} + +export { tlonSetupWizard }; diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 9282fcf92f9..4442279a727 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,212 +1,105 @@ -import crypto from "node:crypto"; -import { configureClient } from "@tloncorp/api"; -import type { - ChannelOutboundAdapter, - ChannelPlugin, - OpenClawConfig, -} from "openclaw/plugin-sdk/tlon"; +import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/tlon"; import { tlonChannelConfigSchema } from "./config-schema.js"; -import { monitorTlonProvider } from "./monitor/index.js"; import { tlonSetupAdapter } from "./setup-core.js"; -import { tlonSetupWizard } from "./setup-surface.js"; +import { applyTlonSetupConfig } from "./setup-core.js"; import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; -import { authenticate } from "./urbit/auth.js"; -import { ssrfPolicyFromAllowPrivateNetwork } from "./urbit/context.js"; -import { urbitFetch } from "./urbit/fetch.js"; -import { - buildMediaStory, - sendDm, - sendGroupMessage, - sendDmWithStory, - sendGroupMessageWithStory, -} from "./urbit/send.js"; -import { uploadImageFromUrl } from "./urbit/upload.js"; - -// Simple HTTP-only poke that doesn't open an EventSource (avoids conflict with monitor's SSE) -async function createHttpPokeApi(params: { - url: string; - code: string; - ship: string; - allowPrivateNetwork?: boolean; -}) { - const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(params.allowPrivateNetwork); - const cookie = await authenticate(params.url, params.code, { ssrfPolicy }); - const channelId = `${Math.floor(Date.now() / 1000)}-${crypto.randomUUID()}`; - const channelPath = `/~/channel/${channelId}`; - const shipName = params.ship.replace(/^~/, ""); - - return { - poke: async (pokeParams: { app: string; mark: string; json: unknown }) => { - const pokeId = Date.now(); - const pokeData = { - id: pokeId, - action: "poke", - ship: shipName, - app: pokeParams.app, - mark: pokeParams.mark, - json: pokeParams.json, - }; - - // Use urbitFetch for consistent SSRF protection (DNS pinning + redirect handling) - const { response, release } = await urbitFetch({ - baseUrl: params.url, - path: channelPath, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: cookie.split(";")[0], - }, - body: JSON.stringify([pokeData]), - }, - ssrfPolicy, - auditContext: "tlon-poke", - }); - - try { - if (!response.ok && response.status !== 204) { - const errorText = await response.text(); - throw new Error(`Poke failed: ${response.status} - ${errorText}`); - } - - return pokeId; - } finally { - await release(); - } - }, - delete: async () => { - // No-op for HTTP-only client - }, - }; -} +import { validateUrbitBaseUrl } from "./urbit/base-url.js"; const TLON_CHANNEL_ID = "tlon" as const; -type ResolvedTlonAccount = ReturnType; -type ConfiguredTlonAccount = ResolvedTlonAccount & { - ship: string; - url: string; - code: string; -}; +let tlonChannelRuntimePromise: Promise | null = null; -function resolveOutboundContext(params: { - cfg: OpenClawConfig; - accountId?: string | null; - to: string; -}) { - const account = resolveTlonAccount(params.cfg, params.accountId ?? undefined); - if (!account.configured || !account.ship || !account.url || !account.code) { - throw new Error("Tlon account not configured"); - } - - const parsed = parseTlonTarget(params.to); - if (!parsed) { - throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); - } - - return { account: account as ConfiguredTlonAccount, parsed }; +async function loadTlonChannelRuntime() { + tlonChannelRuntimePromise ??= import("./channel.runtime.js"); + return tlonChannelRuntimePromise; } -function resolveReplyId(replyToId?: string | null, threadId?: string | number | null) { - return (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined; -} - -async function withHttpPokeAccountApi( - account: ConfiguredTlonAccount, - run: (api: Awaited>) => Promise, -) { - const api = await createHttpPokeApi({ - url: account.url, - ship: account.ship, - code: account.code, - allowPrivateNetwork: account.allowPrivateNetwork ?? undefined, - }); - - try { - return await run(api); - } finally { - try { - await api.delete(); - } catch { - // ignore cleanup errors - } - } -} - -const tlonOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - textChunkLimit: 10000, - resolveTarget: ({ to }) => { - const parsed = parseTlonTarget(to ?? ""); - if (!parsed) { - return { - ok: false, - error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`), - }; - } - if (parsed.kind === "dm") { - return { ok: true, to: parsed.ship }; - } - return { ok: true, to: parsed.nest }; +const tlonSetupWizardProxy = { + channel: "tlon", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "configured", + unconfiguredHint: "urbit messenger", + configuredScore: 1, + unconfiguredScore: 4, + resolveConfigured: async ({ cfg }) => + await (await loadTlonChannelRuntime()).tlonSetupWizard.status.resolveConfigured({ cfg }), + resolveStatusLines: async ({ cfg, configured }) => + (await ( + await loadTlonChannelRuntime() + ).tlonSetupWizard.status.resolveStatusLines?.({ + cfg, + configured, + })) ?? [], }, - sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { - const { account, parsed } = resolveOutboundContext({ cfg, accountId, to }); - return withHttpPokeAccountApi(account, async (api) => { - const fromShip = normalizeShip(account.ship); - if (parsed.kind === "dm") { - return await sendDm({ - api, - fromShip, - toShip: parsed.ship, - text, - }); - } - return await sendGroupMessage({ - api, - fromShip, - hostShip: parsed.hostShip, - channelName: parsed.channelName, - text, - replyToId: resolveReplyId(replyToId, threadId), - }); - }); + introNote: { + title: "Tlon setup", + lines: [ + "You need your Urbit ship URL and login code.", + "Example URL: https://your-ship-host", + "Example ship: ~sampel-palnet", + "If your ship URL is on a private network (LAN/localhost), you must explicitly allow it during setup.", + "Docs: https://docs.openclaw.ai/channels/tlon", + ], }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => { - const { account, parsed } = resolveOutboundContext({ cfg, accountId, to }); - - // Configure the API client for uploads - configureClient({ - shipUrl: account.url, - shipName: account.ship.replace(/^~/, ""), - verbose: false, - getCode: async () => account.code, - }); - - const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined; - return withHttpPokeAccountApi(account, async (api) => { - const fromShip = normalizeShip(account.ship); - const story = buildMediaStory(text, uploadedUrl); - - if (parsed.kind === "dm") { - return await sendDmWithStory({ - api, - fromShip, - toShip: parsed.ship, - story, - }); - } - return await sendGroupMessageWithStory({ - api, - fromShip, - hostShip: parsed.hostShip, - channelName: parsed.channelName, - story, - replyToId: resolveReplyId(replyToId, threadId), - }); - }); - }, -}; + credentials: [], + textInputs: [ + { + inputKey: "ship", + message: "Ship name", + placeholder: "~sampel-palnet", + currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).ship ?? undefined, + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => normalizeShip(String(value).trim()), + applySet: async ({ cfg, accountId, value }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: { ship: value }, + }), + }, + { + inputKey: "url", + message: "Ship URL", + placeholder: "https://your-ship-host", + currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).url ?? undefined, + validate: ({ value }) => { + const next = validateUrbitBaseUrl(String(value ?? "")); + if (!next.ok) { + return next.error; + } + return undefined; + }, + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: { url: value }, + }), + }, + { + inputKey: "code", + message: "Login code", + placeholder: "lidlut-tabwed-pillex-ridrup", + currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).code ?? undefined, + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: { code: value }, + }), + }, + ], + finalize: async (params) => + await ( + await loadTlonChannelRuntime() + ).tlonSetupWizard.finalize!(params), +} satisfies NonNullable; export const tlonPlugin: ChannelPlugin = { id: TLON_CHANNEL_ID, @@ -227,7 +120,7 @@ export const tlonPlugin: ChannelPlugin = { threads: true, }, setup: tlonSetupAdapter, - setupWizard: tlonSetupWizard, + setupWizard: tlonSetupWizardProxy, reload: { configPrefixes: ["channels.tlon"] }, configSchema: tlonChannelConfigSchema, config: { @@ -321,7 +214,31 @@ export const tlonPlugin: ChannelPlugin = { hint: formatTargetHint(), }, }, - outbound: tlonOutbound, + outbound: { + deliveryMode: "direct", + textChunkLimit: 10000, + resolveTarget: ({ to }) => { + const parsed = parseTlonTarget(to ?? ""); + if (!parsed) { + return { + ok: false, + error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`), + }; + } + if (parsed.kind === "dm") { + return { ok: true, to: parsed.ship }; + } + return { ok: true, to: parsed.nest }; + }, + sendText: async (params) => + await ( + await loadTlonChannelRuntime() + ).tlonRuntimeOutbound.sendText!(params), + sendMedia: async (params) => + await ( + await loadTlonChannelRuntime() + ).tlonRuntimeOutbound.sendMedia!(params), + }, status: { defaultRuntime: { accountId: "default", @@ -357,32 +274,7 @@ export const tlonPlugin: ChannelPlugin = { if (!account.configured || !account.ship || !account.url || !account.code) { return { ok: false, error: "Not configured" }; } - try { - const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork); - const cookie = await authenticate(account.url, account.code, { ssrfPolicy }); - // Simple probe - just verify we can reach /~/name - const { response, release } = await urbitFetch({ - baseUrl: account.url, - path: "/~/name", - init: { - method: "GET", - headers: { Cookie: cookie }, - }, - ssrfPolicy, - timeoutMs: 30_000, - auditContext: "tlon-probe-account", - }); - try { - if (!response.ok) { - return { ok: false, error: `Name request failed: ${response.status}` }; - } - return { ok: true }; - } finally { - await release(); - } - } catch (error) { - return { ok: false, error: (error as { message?: string })?.message ?? String(error) }; - } + return await (await loadTlonChannelRuntime()).probeTlonAccount(account as never); }, buildAccountSnapshot: ({ account, runtime, probe }) => { // Tlon-specific snapshot with ship/url for status display @@ -403,19 +295,7 @@ export const tlonPlugin: ChannelPlugin = { }, }, gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - ctx.setStatus({ - accountId: account.accountId, - ship: account.ship, - url: account.url, - } as import("openclaw/plugin-sdk/tlon").ChannelAccountSnapshot); - ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`); - return monitorTlonProvider({ - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - accountId: account.accountId, - }); - }, + startAccount: async (ctx) => + await (await loadTlonChannelRuntime()).startTlonGatewayAccount(ctx), }, }; diff --git a/extensions/together/index.ts b/extensions/together/index.ts index 7408fbea140..cb4113b6009 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -1,10 +1,10 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildTogetherProvider } from "../../src/agents/models-config.providers.static.js"; import { applyTogetherConfig, TOGETHER_DEFAULT_MODEL_REF, } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildTogetherProvider } from "./provider-catalog.js"; const PROVIDER_ID = "together"; diff --git a/extensions/together/provider-catalog.ts b/extensions/together/provider-catalog.ts new file mode 100644 index 00000000000..3d902d3bb1a --- /dev/null +++ b/extensions/together/provider-catalog.ts @@ -0,0 +1,14 @@ +import { + buildTogetherModelDefinition, + TOGETHER_BASE_URL, + TOGETHER_MODEL_CATALOG, +} from "../../src/agents/together-models.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +export function buildTogetherProvider(): ModelProviderConfig { + return { + baseUrl: TOGETHER_BASE_URL, + api: "openai-completions", + models: TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition), + }; +} diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index 12714fc2666..8d3f377d130 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -1,7 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildVeniceProvider } from "../../src/agents/models-config.providers.discovery.js"; import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildVeniceProvider } from "./provider-catalog.js"; const PROVIDER_ID = "venice"; diff --git a/extensions/venice/provider-catalog.ts b/extensions/venice/provider-catalog.ts new file mode 100644 index 00000000000..ec7087a08db --- /dev/null +++ b/extensions/venice/provider-catalog.ts @@ -0,0 +1,11 @@ +import { discoverVeniceModels, VENICE_BASE_URL } from "../../src/agents/venice-models.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +export async function buildVeniceProvider(): Promise { + const models = await discoverVeniceModels(); + return { + baseUrl: VENICE_BASE_URL, + api: "openai-completions", + models, + }; +} diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index a656cf400a7..7946001981e 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -1,10 +1,10 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildVercelAiGatewayProvider } from "../../src/agents/models-config.providers.discovery.js"; import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildVercelAiGatewayProvider } from "./provider-catalog.js"; const PROVIDER_ID = "vercel-ai-gateway"; diff --git a/extensions/vercel-ai-gateway/provider-catalog.ts b/extensions/vercel-ai-gateway/provider-catalog.ts new file mode 100644 index 00000000000..0e219264ab7 --- /dev/null +++ b/extensions/vercel-ai-gateway/provider-catalog.ts @@ -0,0 +1,13 @@ +import { + discoverVercelAiGatewayModels, + VERCEL_AI_GATEWAY_BASE_URL, +} from "../../src/agents/vercel-ai-gateway.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +export async function buildVercelAiGatewayProvider(): Promise { + return { + baseUrl: VERCEL_AI_GATEWAY_BASE_URL, + api: "anthropic-messages", + models: await discoverVercelAiGatewayModels(), + }; +} 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/voice-call/README.md b/extensions/voice-call/README.md index fe228537ee8..36ab127875e 100644 --- a/extensions/voice-call/README.md +++ b/extensions/voice-call/README.md @@ -98,7 +98,7 @@ See the plugin docs for recommended ranges and production examples: ## TTS for calls -Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for +Voice Call uses the core `messages.tts` configuration for streaming speech on calls. Override examples and provider caveats live here: `https://docs.openclaw.ai/plugins/voice-call#tts-for-calls` diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 7393fb03c9b..f20e2da6674 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -80,7 +80,7 @@ const voiceCallConfigSchema = { "streaming.streamPath": { label: "Media Stream Path", advanced: true }, "tts.provider": { label: "TTS Provider Override", - help: "Deep-merges with messages.tts (Edge is ignored for calls).", + help: "Deep-merges with messages.tts (Microsoft is ignored for calls).", advanced: true, }, "tts.openai.model": { label: "OpenAI TTS Model", advanced: true }, @@ -180,6 +180,7 @@ const voiceCallPlugin = { runtimePromise = createVoiceCallRuntime({ config, coreConfig: api.config as CoreConfig, + agentRuntime: api.runtime.agent, ttsRuntime: api.runtime.tts, logger: api.logger, }); diff --git a/extensions/voice-call/openclaw.plugin.json b/extensions/voice-call/openclaw.plugin.json index fef3ccc6ad9..ff85a30a947 100644 --- a/extensions/voice-call/openclaw.plugin.json +++ b/extensions/voice-call/openclaw.plugin.json @@ -101,7 +101,7 @@ }, "tts.provider": { "label": "TTS Provider Override", - "help": "Deep-merges with messages.tts (Edge is ignored for calls).", + "help": "Deep-merges with messages.tts (Microsoft is ignored for calls).", "advanced": true }, "tts.openai.model": { @@ -420,8 +420,7 @@ "enum": ["final", "all"] }, "provider": { - "type": "string", - "enum": ["openai", "elevenlabs", "edge"] + "type": "string" }, "summaryModel": { "type": "string" diff --git a/extensions/voice-call/src/core-bridge.ts b/extensions/voice-call/src/core-bridge.ts index 0425eef9dbd..13ed56302fe 100644 --- a/extensions/voice-call/src/core-bridge.ts +++ b/extensions/voice-call/src/core-bridge.ts @@ -1,6 +1,4 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/voice-call"; import type { VoiceCallTtsConfig } from "./config.js"; export type CoreConfig = { @@ -13,147 +11,4 @@ export type CoreConfig = { [key: string]: unknown; }; -type CoreAgentDeps = { - resolveAgentDir: (cfg: CoreConfig, agentId: string) => string; - resolveAgentWorkspaceDir: (cfg: CoreConfig, agentId: string) => string; - resolveAgentIdentity: ( - cfg: CoreConfig, - agentId: string, - ) => { name?: string | null } | null | undefined; - resolveThinkingDefault: (params: { - cfg: CoreConfig; - provider?: string; - model?: string; - }) => string; - runEmbeddedPiAgent: (params: { - sessionId: string; - sessionKey?: string; - messageProvider?: string; - sessionFile: string; - workspaceDir: string; - config?: CoreConfig; - prompt: string; - provider?: string; - model?: string; - thinkLevel?: string; - verboseLevel?: string; - timeoutMs: number; - runId: string; - lane?: string; - extraSystemPrompt?: string; - agentDir?: string; - }) => Promise<{ - payloads?: Array<{ text?: string; isError?: boolean }>; - meta?: { aborted?: boolean }; - }>; - resolveAgentTimeoutMs: (opts: { cfg: CoreConfig }) => number; - ensureAgentWorkspace: (params?: { dir: string }) => Promise; - resolveStorePath: (store?: string, opts?: { agentId?: string }) => string; - loadSessionStore: (storePath: string) => Record; - saveSessionStore: (storePath: string, store: Record) => Promise; - resolveSessionFilePath: ( - sessionId: string, - entry: unknown, - opts?: { agentId?: string }, - ) => string; - DEFAULT_MODEL: string; - DEFAULT_PROVIDER: string; -}; - -let coreRootCache: string | null = null; -let coreDepsPromise: Promise | null = null; - -function findPackageRoot(startDir: string, name: string): string | null { - let dir = startDir; - for (;;) { - const pkgPath = path.join(dir, "package.json"); - try { - if (fs.existsSync(pkgPath)) { - const raw = fs.readFileSync(pkgPath, "utf8"); - const pkg = JSON.parse(raw) as { name?: string }; - if (pkg.name === name) { - return dir; - } - } - } catch { - // ignore parse errors and keep walking - } - const parent = path.dirname(dir); - if (parent === dir) { - return null; - } - dir = parent; - } -} - -function resolveOpenClawRoot(): string { - if (coreRootCache) { - return coreRootCache; - } - const override = process.env.OPENCLAW_ROOT?.trim(); - if (override) { - coreRootCache = override; - return override; - } - - const candidates = new Set(); - if (process.argv[1]) { - candidates.add(path.dirname(process.argv[1])); - } - candidates.add(process.cwd()); - try { - const urlPath = fileURLToPath(import.meta.url); - candidates.add(path.dirname(urlPath)); - } catch { - // ignore - } - - for (const start of candidates) { - for (const name of ["openclaw"]) { - const found = findPackageRoot(start, name); - if (found) { - coreRootCache = found; - return found; - } - } - } - - throw new Error("Unable to resolve core root. Set OPENCLAW_ROOT to the package root."); -} - -async function importCoreExtensionAPI(): Promise<{ - resolveAgentDir: CoreAgentDeps["resolveAgentDir"]; - resolveAgentWorkspaceDir: CoreAgentDeps["resolveAgentWorkspaceDir"]; - DEFAULT_MODEL: string; - DEFAULT_PROVIDER: string; - resolveAgentIdentity: CoreAgentDeps["resolveAgentIdentity"]; - resolveThinkingDefault: CoreAgentDeps["resolveThinkingDefault"]; - runEmbeddedPiAgent: CoreAgentDeps["runEmbeddedPiAgent"]; - resolveAgentTimeoutMs: CoreAgentDeps["resolveAgentTimeoutMs"]; - ensureAgentWorkspace: CoreAgentDeps["ensureAgentWorkspace"]; - resolveStorePath: CoreAgentDeps["resolveStorePath"]; - loadSessionStore: CoreAgentDeps["loadSessionStore"]; - saveSessionStore: CoreAgentDeps["saveSessionStore"]; - resolveSessionFilePath: CoreAgentDeps["resolveSessionFilePath"]; -}> { - // Do not import any other module. You can't touch this or you will be fired. - const distPath = path.join(resolveOpenClawRoot(), "dist", "extensionAPI.js"); - if (!fs.existsSync(distPath)) { - throw new Error( - `Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`, - ); - } - return await import(pathToFileURL(distPath).href); -} - -export async function loadCoreAgentDeps(): Promise { - if (coreDepsPromise) { - return coreDepsPromise; - } - - coreDepsPromise = (async () => { - return await importCoreExtensionAPI(); - })(); - - return coreDepsPromise; -} +export type CoreAgentDeps = OpenClawPluginApi["runtime"]["agent"]; diff --git a/extensions/voice-call/src/response-generator.ts b/extensions/voice-call/src/response-generator.ts index abb02cb7b1d..3c8a45eadfb 100644 --- a/extensions/voice-call/src/response-generator.ts +++ b/extensions/voice-call/src/response-generator.ts @@ -4,14 +4,17 @@ */ import crypto from "node:crypto"; +import type { SessionEntry } from "openclaw/plugin-sdk/voice-call"; import type { VoiceCallConfig } from "./config.js"; -import { loadCoreAgentDeps, type CoreConfig } from "./core-bridge.js"; +import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; export type VoiceResponseParams = { /** Voice call config */ voiceConfig: VoiceCallConfig; /** Core OpenClaw config */ coreConfig: CoreConfig; + /** Injected host agent runtime */ + agentRuntime: CoreAgentDeps; /** Call ID for session tracking */ callId: string; /** Caller's phone number */ @@ -27,11 +30,6 @@ export type VoiceResponseResult = { error?: string; }; -type SessionEntry = { - sessionId: string; - updatedAt: number; -}; - /** * Generate a voice response using the embedded Pi agent with full tool support. * Uses the same agent infrastructure as messaging for consistent behavior. @@ -39,21 +37,11 @@ type SessionEntry = { export async function generateVoiceResponse( params: VoiceResponseParams, ): Promise { - const { voiceConfig, callId, from, transcript, userMessage, coreConfig } = params; + const { voiceConfig, callId, from, transcript, userMessage, coreConfig, agentRuntime } = params; if (!coreConfig) { return { text: null, error: "Core config unavailable for voice response" }; } - - let deps: Awaited>; - try { - deps = await loadCoreAgentDeps(); - } catch (err) { - return { - text: null, - error: err instanceof Error ? err.message : "Unable to load core agent dependencies", - }; - } const cfg = coreConfig; // Build voice-specific session key based on phone number @@ -62,15 +50,15 @@ export async function generateVoiceResponse( const agentId = "main"; // Resolve paths - const storePath = deps.resolveStorePath(cfg.session?.store, { agentId }); - const agentDir = deps.resolveAgentDir(cfg, agentId); - const workspaceDir = deps.resolveAgentWorkspaceDir(cfg, agentId); + const storePath = agentRuntime.session.resolveStorePath(cfg.session?.store, { agentId }); + const agentDir = agentRuntime.resolveAgentDir(cfg, agentId); + const workspaceDir = agentRuntime.resolveAgentWorkspaceDir(cfg, agentId); // Ensure workspace exists - await deps.ensureAgentWorkspace({ dir: workspaceDir }); + await agentRuntime.ensureAgentWorkspace({ dir: workspaceDir }); // Load or create session entry - const sessionStore = deps.loadSessionStore(storePath); + const sessionStore = agentRuntime.session.loadSessionStore(storePath); const now = Date.now(); let sessionEntry = sessionStore[sessionKey] as SessionEntry | undefined; @@ -80,25 +68,27 @@ export async function generateVoiceResponse( updatedAt: now, }; sessionStore[sessionKey] = sessionEntry; - await deps.saveSessionStore(storePath, sessionStore); + await agentRuntime.session.saveSessionStore(storePath, sessionStore); } const sessionId = sessionEntry.sessionId; - const sessionFile = deps.resolveSessionFilePath(sessionId, sessionEntry, { + const sessionFile = agentRuntime.session.resolveSessionFilePath(sessionId, sessionEntry, { agentId, }); // Resolve model from config - const modelRef = voiceConfig.responseModel || `${deps.DEFAULT_PROVIDER}/${deps.DEFAULT_MODEL}`; + const modelRef = + voiceConfig.responseModel || `${agentRuntime.defaults.provider}/${agentRuntime.defaults.model}`; const slashIndex = modelRef.indexOf("/"); - const provider = slashIndex === -1 ? deps.DEFAULT_PROVIDER : modelRef.slice(0, slashIndex); + const provider = + slashIndex === -1 ? agentRuntime.defaults.provider : modelRef.slice(0, slashIndex); const model = slashIndex === -1 ? modelRef : modelRef.slice(slashIndex + 1); // Resolve thinking level - const thinkLevel = deps.resolveThinkingDefault({ cfg, provider, model }); + const thinkLevel = agentRuntime.resolveThinkingDefault({ cfg, provider, model }); // Resolve agent identity for personalized prompt - const identity = deps.resolveAgentIdentity(cfg, agentId); + const identity = agentRuntime.resolveAgentIdentity(cfg, agentId); const agentName = identity?.name?.trim() || "assistant"; // Build system prompt with conversation history @@ -115,11 +105,11 @@ export async function generateVoiceResponse( } // Resolve timeout - const timeoutMs = voiceConfig.responseTimeoutMs ?? deps.resolveAgentTimeoutMs({ cfg }); + const timeoutMs = voiceConfig.responseTimeoutMs ?? agentRuntime.resolveAgentTimeoutMs({ cfg }); const runId = `voice:${callId}:${Date.now()}`; try { - const result = await deps.runEmbeddedPiAgent({ + const result = await agentRuntime.runEmbeddedPiAgent({ sessionId, sessionKey, messageProvider: "voice", diff --git a/extensions/voice-call/src/runtime.test.ts b/extensions/voice-call/src/runtime.test.ts index dcb8fa2a158..ffe9093c4e2 100644 --- a/extensions/voice-call/src/runtime.test.ts +++ b/extensions/voice-call/src/runtime.test.ts @@ -76,6 +76,7 @@ describe("createVoiceCallRuntime lifecycle", () => { createVoiceCallRuntime({ config: createBaseConfig(), coreConfig: {}, + agentRuntime: {} as never, }), ).rejects.toThrow("init failed"); @@ -95,6 +96,7 @@ describe("createVoiceCallRuntime lifecycle", () => { const runtime = await createVoiceCallRuntime({ config: createBaseConfig(), coreConfig: {} as CoreConfig, + agentRuntime: {} as never, }); await runtime.stop(); diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index d725e44bf06..384ac209a76 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -1,6 +1,6 @@ import type { VoiceCallConfig } from "./config.js"; import { resolveVoiceCallConfig, validateProviderConfig } from "./config.js"; -import type { CoreConfig } from "./core-bridge.js"; +import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; import { CallManager } from "./manager.js"; import type { VoiceCallProvider } from "./providers/base.js"; import { MockProvider } from "./providers/mock.js"; @@ -135,10 +135,11 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider { export async function createVoiceCallRuntime(params: { config: VoiceCallConfig; coreConfig: CoreConfig; + agentRuntime: CoreAgentDeps; ttsRuntime?: TelephonyTtsRuntime; logger?: Logger; }): Promise { - const { config: rawConfig, coreConfig, ttsRuntime, logger } = params; + const { config: rawConfig, coreConfig, agentRuntime, ttsRuntime, logger } = params; const log = logger ?? { info: console.log, warn: console.warn, @@ -165,7 +166,13 @@ export async function createVoiceCallRuntime(params: { const provider = resolveProvider(config); const manager = new CallManager(config); - const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig); + const webhookServer = new VoiceCallWebhookServer( + config, + manager, + provider, + coreConfig, + agentRuntime, + ); const lifecycle = createRuntimeResourceLifecycle({ config, webhookServer }); const localUrl = await webhookServer.start(); diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 1258229735e..2b79309c9f0 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -6,7 +6,7 @@ import { requestBodyErrorToText, } from "openclaw/plugin-sdk/voice-call"; import { normalizeVoiceCallConfig, type VoiceCallConfig } from "./config.js"; -import type { CoreConfig } from "./core-bridge.js"; +import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; import type { CallManager } from "./manager.js"; import type { MediaStreamConfig } from "./media-stream.js"; import { MediaStreamHandler } from "./media-stream.js"; @@ -55,6 +55,7 @@ export class VoiceCallWebhookServer { private manager: CallManager; private provider: VoiceCallProvider; private coreConfig: CoreConfig | null; + private agentRuntime: CoreAgentDeps | null; private stopStaleCallReaper: (() => void) | null = null; /** Media stream handler for bidirectional audio (when streaming enabled) */ @@ -65,11 +66,13 @@ export class VoiceCallWebhookServer { manager: CallManager, provider: VoiceCallProvider, coreConfig?: CoreConfig, + agentRuntime?: CoreAgentDeps, ) { this.config = normalizeVoiceCallConfig(config); this.manager = manager; this.provider = provider; this.coreConfig = coreConfig ?? null; + this.agentRuntime = agentRuntime ?? null; // Initialize media stream handler if streaming is enabled if (this.config.streaming.enabled) { @@ -458,6 +461,10 @@ export class VoiceCallWebhookServer { console.warn("[voice-call] Core config missing; skipping auto-response"); return; } + if (!this.agentRuntime) { + console.warn("[voice-call] Agent runtime missing; skipping auto-response"); + return; + } try { const { generateVoiceResponse } = await import("./response-generator.js"); @@ -465,6 +472,7 @@ export class VoiceCallWebhookServer { const result = await generateVoiceResponse({ voiceConfig: this.config, coreConfig: this.coreConfig, + agentRuntime: this.agentRuntime, callId, from: call.from, transcript: call.transcript, diff --git a/extensions/volcengine/index.ts b/extensions/volcengine/index.ts index 2e6063365df..4fadadb3608 100644 --- a/extensions/volcengine/index.ts +++ b/extensions/volcengine/index.ts @@ -1,10 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { - buildDoubaoCodingProvider, - buildDoubaoProvider, -} from "../../src/agents/models-config.providers.static.js"; import { ensureModelAllowlistEntry } from "../../src/commands/model-allowlist.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildDoubaoCodingProvider, buildDoubaoProvider } from "./provider-catalog.js"; const PROVIDER_ID = "volcengine"; const VOLCENGINE_DEFAULT_MODEL_REF = "volcengine-plan/ark-code-latest"; diff --git a/extensions/volcengine/provider-catalog.ts b/extensions/volcengine/provider-catalog.ts new file mode 100644 index 00000000000..ef57e0a86e7 --- /dev/null +++ b/extensions/volcengine/provider-catalog.ts @@ -0,0 +1,24 @@ +import { + buildDoubaoModelDefinition, + DOUBAO_BASE_URL, + DOUBAO_CODING_BASE_URL, + DOUBAO_CODING_MODEL_CATALOG, + DOUBAO_MODEL_CATALOG, +} from "../../src/agents/doubao-models.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +export function buildDoubaoProvider(): ModelProviderConfig { + return { + baseUrl: DOUBAO_BASE_URL, + api: "openai-completions", + models: DOUBAO_MODEL_CATALOG.map(buildDoubaoModelDefinition), + }; +} + +export function buildDoubaoCodingProvider(): ModelProviderConfig { + return { + baseUrl: DOUBAO_CODING_BASE_URL, + api: "openai-completions", + models: DOUBAO_CODING_MODEL_CATALOG.map(buildDoubaoModelDefinition), + }; +} 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/accounts.ts b/extensions/whatsapp/src/accounts.ts index 53e73128894..c607840dcd3 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -1,16 +1,15 @@ import fs from "node:fs"; import path from "node:path"; -import type { - DmPolicy, - GroupPolicy, - OpenClawConfig, - WhatsAppAccountConfig, -} from "openclaw/plugin-sdk/whatsapp"; -import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; +import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "openclaw/plugin-sdk/whatsapp"; import { resolveOAuthDir } from "../../../src/config/paths.js"; -import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { resolveUserPath } from "../../../src/utils.js"; +import { + type OpenClawConfig, + createAccountListHelpers, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + resolveAccountEntry, + resolveUserPath, +} from "../../../src/plugin-sdk-internal/accounts.js"; import { hasWebCredsSync } from "./auth-store.js"; export type ResolvedWhatsAppAccount = { diff --git a/extensions/whatsapp/src/normalize.ts b/extensions/whatsapp/src/normalize.ts index 319dabe25bd..82ee5d8296d 100644 --- a/extensions/whatsapp/src/normalize.ts +++ b/extensions/whatsapp/src/normalize.ts @@ -1,28 +1,5 @@ -import { - looksLikeHandleOrPhoneTarget, - trimMessagingTarget, -} from "../../../src/channels/plugins/normalize/shared.js"; -import { normalizeWhatsAppTarget } from "../../../src/whatsapp/normalize.js"; - -export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined { - const trimmed = trimMessagingTarget(raw); - if (!trimmed) { - return undefined; - } - return normalizeWhatsAppTarget(trimmed) ?? undefined; -} - -export function normalizeWhatsAppAllowFromEntries(allowFrom: Array): string[] { - return allowFrom - .map((entry) => String(entry).trim()) - .filter((entry): entry is string => Boolean(entry)) - .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) - .filter((entry): entry is string => Boolean(entry)); -} - -export function looksLikeWhatsAppTargetId(raw: string): boolean { - return looksLikeHandleOrPhoneTarget({ - raw, - prefixPattern: /^whatsapp:/i, - }); -} +export { + looksLikeWhatsAppTargetId, + normalizeWhatsAppAllowFromEntries, + normalizeWhatsAppMessagingTarget, +} from "../../../src/channels/plugins/normalize/whatsapp.js"; 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/whatsapp/src/setup-core.ts b/extensions/whatsapp/src/setup-core.ts index 2b243743076..a4471eb8188 100644 --- a/extensions/whatsapp/src/setup-core.ts +++ b/extensions/whatsapp/src/setup-core.ts @@ -1,9 +1,9 @@ import { applyAccountNameToChannelSection, + type ChannelSetupAdapter, migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; + normalizeAccountId, +} from "../../../src/plugin-sdk-internal/setup.js"; const channel = "whatsapp" as const; diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 4210b5772af..e2ec4149631 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -1,23 +1,48 @@ import path from "node:path"; -import { loginWeb } from "../../../src/channel-web.js"; +import type { DmPolicy } from "openclaw/plugin-sdk/whatsapp"; import { + DEFAULT_ACCOUNT_ID, + formatCliCommand, + formatDocsLink, + normalizeAccountId, normalizeAllowFromEntries, + normalizeE164, + pathExists, splitSetupEntries, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import { setSetupChannelEnabled } from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { mergeWhatsAppConfig } from "../../../src/config/merge-config.js"; -import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import { normalizeE164, pathExists } from "../../../src/utils.js"; + setSetupChannelEnabled, + type OpenClawConfig, +} from "../../../src/plugin-sdk-internal/setup.js"; +import type { ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; +import { loginWeb } from "./login.js"; import { whatsappSetupAdapter } from "./setup-core.js"; const channel = "whatsapp" as const; +function mergeWhatsAppConfig( + cfg: OpenClawConfig, + patch: Partial["whatsapp"]>>, + options?: { unsetOnUndefined?: string[] }, +): OpenClawConfig { + const base = { ...(cfg.channels?.whatsapp ?? {}) } as Record; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + if (options?.unsetOnUndefined?.includes(key)) { + delete base[key]; + } + continue; + } + base[key] = value; + } + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: base, + }, + }; +} + function setWhatsAppDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { return mergeWhatsAppConfig(cfg, { dmPolicy }); } 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/xiaomi/index.ts b/extensions/xiaomi/index.ts index 4987b18c8fd..2b87dfee12a 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -1,8 +1,8 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildXiaomiProvider } from "../../src/agents/models-config.providers.static.js"; import { applyXiaomiConfig, XIAOMI_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js"; import { PROVIDER_LABELS } from "../../src/infra/provider-usage.shared.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildXiaomiProvider } from "./provider-catalog.js"; const PROVIDER_ID = "xiaomi"; diff --git a/extensions/xiaomi/provider-catalog.ts b/extensions/xiaomi/provider-catalog.ts new file mode 100644 index 00000000000..b62de84cf68 --- /dev/null +++ b/extensions/xiaomi/provider-catalog.ts @@ -0,0 +1,30 @@ +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic"; +export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash"; +const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144; +const XIAOMI_DEFAULT_MAX_TOKENS = 8192; +const XIAOMI_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export function buildXiaomiProvider(): ModelProviderConfig { + return { + baseUrl: XIAOMI_BASE_URL, + api: "anthropic-messages", + models: [ + { + id: XIAOMI_DEFAULT_MODEL_ID, + name: "Xiaomi MiMo V2 Flash", + reasoning: false, + input: ["text"], + cost: XIAOMI_DEFAULT_COST, + contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW, + maxTokens: XIAOMI_DEFAULT_MAX_TOKENS, + }, + ], + }; +} diff --git a/knip.config.ts b/knip.config.ts index 6a76a8238b7..9ceda2575d8 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -3,7 +3,6 @@ const rootEntries = [ "src/index.ts!", "src/entry.ts!", "src/cli/daemon-cli.ts!", - "src/extensionAPI.ts!", "src/infra/warning-filter.ts!", "src/channels/plugins/agent-tools/whatsapp-login.ts!", "src/channels/plugins/actions/discord.ts!", 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 ebaa3607ad1..f0904418919 100644 --- a/package.json +++ b/package.json @@ -50,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" @@ -214,7 +230,6 @@ "types": "./dist/plugin-sdk/keyed-async-queue.d.ts", "default": "./dist/plugin-sdk/keyed-async-queue.js" }, - "./extension-api": "./dist/extensionAPI.js", "./cli-entry": "./openclaw.mjs" }, "scripts": { diff --git a/scripts/check-cli-startup-memory.mjs b/scripts/check-cli-startup-memory.mjs index fcbd63d8d11..f2e8521961e 100644 --- a/scripts/check-cli-startup-memory.mjs +++ b/scripts/check-cli-startup-memory.mjs @@ -62,12 +62,39 @@ const cases = [ }, ]; +function formatFixGuidance(testCase, details) { + const command = `node ${testCase.args.join(" ")}`; + const guidance = [ + "[startup-memory] Fix guidance", + `Case: ${testCase.label}`, + `Command: ${command}`, + "Next steps:", + `1. Run \`${command}\` locally on the built tree.`, + "2. If this is an RSS overage, compare the startup import graph against the last passing commit and look for newly eager imports, bootstrap side effects, or plugin loading on the command path.", + "3. If this is a non-zero exit, inspect the first transitive import/config error in stderr and fix that root cause before re-checking memory.", + "LLM prompt:", + `"OpenClaw startup-memory CI failed for '${testCase.label}'. Analyze this failure, identify the first runtime/import side effect that makes startup heavier or broken, and propose the smallest safe patch. Failure output:\n${details}"`, + ]; + return `${guidance.join("\n")}\n`; +} + +function formatFailure(testCase, message, details = "") { + const trimmedDetails = details.trim(); + const sections = [message]; + if (trimmedDetails) { + sections.push(trimmedDetails); + } + sections.push(formatFixGuidance(testCase, trimmedDetails || message)); + return sections.join("\n\n"); +} + 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 +125,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; } @@ -116,18 +146,27 @@ function runCase(testCase) { if (result.status !== 0) { throw new Error( - `${testCase.label} exited with ${String(result.status)}\n${stderr.trim() || result.stdout || ""}`, + formatFailure( + testCase, + `${testCase.label} exited with ${String(result.status)}`, + stderr.trim() || result.stdout || "", + ), ); } if (maxRssMb == null) { - throw new Error(`${testCase.label} did not report max RSS\n${stderr.trim()}`); + throw new Error(formatFailure(testCase, `${testCase.label} did not report max RSS`, stderr)); } if (matrixBootstrapWarning) { - throw new Error(`${testCase.label} triggered Matrix crypto bootstrap during startup`); + throw new Error( + formatFailure(testCase, `${testCase.label} triggered Matrix crypto bootstrap during startup`), + ); } if (maxRssMb > testCase.limitMb) { throw new Error( - `${testCase.label} used ${maxRssMb.toFixed(1)} MB RSS (limit ${testCase.limitMb} MB)`, + formatFailure( + testCase, + `${testCase.label} used ${maxRssMb.toFixed(1)} MB RSS (limit ${testCase.limitMb} MB)`, + ), ); } 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/release-check.ts b/scripts/release-check.ts index 7eedc970103..9b67303b4a6 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -25,7 +25,7 @@ const requiredPathGroups = [ "dist/plugin-sdk/root-alias.cjs", "dist/build-info.json", ]; -const forbiddenPrefixes = ["dist/OpenClaw.app/"]; +const forbiddenPrefixes = ["dist-runtime/", "dist/OpenClaw.app/"]; // 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory // startup/doctor OOM reports. Keep enough headroom for the current pack while // failing fast if duplicate/shim content sneaks back into the release artifact. diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 884ba7af036..32dc6a31171 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -1,10 +1,12 @@ import { pathToFileURL } from "node:url"; import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs"; import { copyPluginSdkRootAlias } from "./copy-plugin-sdk-root-alias.mjs"; +import { stageBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mjs"; export function runRuntimePostBuild(params = {}) { copyPluginSdkRootAlias(params); copyBundledPluginMetadata(params); + stageBundledPluginRuntime(params); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { diff --git a/scripts/stage-bundled-plugin-runtime.d.mts b/scripts/stage-bundled-plugin-runtime.d.mts new file mode 100644 index 00000000000..718cac12a8e --- /dev/null +++ b/scripts/stage-bundled-plugin-runtime.d.mts @@ -0,0 +1 @@ +export function stageBundledPluginRuntime(params?: { cwd?: string; repoRoot?: string }): void; diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs new file mode 100644 index 00000000000..d6585d3191a --- /dev/null +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -0,0 +1,131 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { removePathIfExists } from "./runtime-postbuild-shared.mjs"; + +function symlinkType() { + return process.platform === "win32" ? "junction" : "dir"; +} + +function relativeSymlinkTarget(sourcePath, targetPath) { + const relativeTarget = path.relative(path.dirname(targetPath), sourcePath); + return relativeTarget || "."; +} + +function symlinkPath(sourcePath, targetPath, type) { + fs.symlinkSync(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type); +} + +function shouldWrapRuntimeJsFile(sourcePath) { + return path.extname(sourcePath) === ".js"; +} + +function shouldCopyRuntimeFile(sourcePath) { + const relativePath = sourcePath.replace(/\\/g, "/"); + return ( + relativePath.endsWith("/package.json") || + relativePath.endsWith("/openclaw.plugin.json") || + relativePath.endsWith("/.codex-plugin/plugin.json") || + relativePath.endsWith("/.claude-plugin/plugin.json") || + relativePath.endsWith("/.cursor-plugin/plugin.json") + ); +} + +function writeRuntimeModuleWrapper(sourcePath, targetPath) { + const specifier = relativeSymlinkTarget(sourcePath, targetPath).replace(/\\/g, "/"); + const normalizedSpecifier = specifier.startsWith(".") ? specifier : `./${specifier}`; + fs.writeFileSync( + targetPath, + [ + `export * from ${JSON.stringify(normalizedSpecifier)};`, + `import * as module from ${JSON.stringify(normalizedSpecifier)};`, + "export default module.default;", + "", + ].join("\n"), + "utf8", + ); +} + +function stagePluginRuntimeOverlay(sourceDir, targetDir) { + fs.mkdirSync(targetDir, { recursive: true }); + + for (const dirent of fs.readdirSync(sourceDir, { withFileTypes: true })) { + if (dirent.name === "node_modules") { + continue; + } + + const sourcePath = path.join(sourceDir, dirent.name); + const targetPath = path.join(targetDir, dirent.name); + + if (dirent.isDirectory()) { + stagePluginRuntimeOverlay(sourcePath, targetPath); + continue; + } + + if (dirent.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(sourcePath), targetPath); + continue; + } + + if (!dirent.isFile()) { + continue; + } + + if (shouldWrapRuntimeJsFile(sourcePath)) { + writeRuntimeModuleWrapper(sourcePath, targetPath); + continue; + } + + if (shouldCopyRuntimeFile(sourcePath)) { + fs.copyFileSync(sourcePath, targetPath); + continue; + } + + symlinkPath(sourcePath, targetPath); + } +} + +function linkPluginNodeModules(params) { + const runtimeNodeModulesDir = path.join(params.runtimePluginDir, "node_modules"); + removePathIfExists(runtimeNodeModulesDir); + if (!fs.existsSync(params.sourcePluginNodeModulesDir)) { + return; + } + fs.symlinkSync(params.sourcePluginNodeModulesDir, runtimeNodeModulesDir, symlinkType()); +} + +export function stageBundledPluginRuntime(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const distRoot = path.join(repoRoot, "dist"); + const runtimeRoot = path.join(repoRoot, "dist-runtime"); + const sourceExtensionsRoot = path.join(repoRoot, "extensions"); + const distExtensionsRoot = path.join(distRoot, "extensions"); + const runtimeExtensionsRoot = path.join(runtimeRoot, "extensions"); + + if (!fs.existsSync(distExtensionsRoot)) { + removePathIfExists(runtimeRoot); + return; + } + + removePathIfExists(runtimeRoot); + fs.mkdirSync(runtimeExtensionsRoot, { recursive: true }); + + for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + const distPluginDir = path.join(distExtensionsRoot, dirent.name); + const runtimePluginDir = path.join(runtimeExtensionsRoot, dirent.name); + const sourcePluginNodeModulesDir = path.join(sourceExtensionsRoot, dirent.name, "node_modules"); + + stagePluginRuntimeOverlay(distPluginDir, runtimePluginDir); + linkPluginNodeModules({ + runtimePluginDir, + sourcePluginNodeModulesDir, + }); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + stageBundledPluginRuntime(); +} diff --git a/scripts/test-extension.mjs b/scripts/test-extension.mjs index bcc6aa30200..6442556c778 100644 --- a/scripts/test-extension.mjs +++ b/scripts/test-extension.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -46,6 +46,72 @@ function collectTestFiles(rootPath) { 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 listAvailableExtensionIds() { + const extensionsDir = path.join(repoRoot, "extensions"); + if (!fs.existsSync(extensionsDir)) { + return []; + } + + return fs + .readdirSync(extensionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((extensionId) => hasExtensionPackage(extensionId)) + .toSorted((left, right) => left.localeCompare(right)); +} + +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) { + const extensionId = extensionMatch[1]; + if (hasExtensionPackage(extensionId)) { + extensionIds.add(extensionId); + } + 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); @@ -115,17 +181,85 @@ export function resolveExtensionTestPlan(params = {}) { 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"); + 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 args = rawArgs.filter((arg) => arg !== "--" && arg !== "--dry-run" && arg !== "--json"); + const list = rawArgs.includes("--list"); + const listChanged = rawArgs.includes("--list-changed"); + const args = rawArgs.filter( + (arg) => + arg !== "--" && + arg !== "--dry-run" && + arg !== "--json" && + arg !== "--list" && + 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 (list) { + const extensionIds = listAvailableExtensionIds(); + if (json) { + process.stdout.write(`${JSON.stringify({ extensionIds }, null, 2)}\n`); + } else { + for (const extensionId of extensionIds) { + console.log(extensionId); + } + } + return; + } + + 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 (args[0] && !args[0].startsWith("-")) { - targetArg = args.shift(); + if (passthroughArgs[0] && !passthroughArgs[0].startsWith("-")) { + targetArg = passthroughArgs.shift(); } let plan; @@ -138,7 +272,9 @@ async function run() { } if (plan.testFiles.length === 0) { - console.error(`No tests found for ${plan.extensionDir}.`); + console.error( + `No tests found for ${plan.extensionDir}. Run "pnpm test:extension ${plan.extensionId} -- --dry-run" to inspect the resolved roots.`, + ); process.exit(1); } @@ -160,7 +296,7 @@ async function run() { const child = spawn( pnpm, - ["exec", "vitest", "run", "--config", plan.config, ...plan.testFiles, ...args], + ["exec", "vitest", "run", "--config", plan.config, ...plan.testFiles, ...passthroughArgs], { cwd: repoRoot, stdio: "inherit", diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 3e3f254d0ee..55446550f9f 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -5,9 +5,10 @@ import type { SetSessionConfigOptionRequest, SetSessionModeRequest, } from "@agentclientprotocol/sdk"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; import type { EventFrame } from "../gateway/protocol/index.js"; +import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js"; import { createInMemorySessionStore } from "./session.js"; import { AcpGatewayAgent } from "./translator.js"; import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; @@ -119,6 +120,10 @@ async function expectOversizedPromptRejected(params: { sessionId: string; text: sessionStore.clearAllSessionsForTest(); } +beforeEach(() => { + resetProviderRuntimeHookCacheForTest(); +}); + describe("acp session creation rate limit", () => { it("rate limits excessive newSession bursts", async () => { const sessionStore = createInMemorySessionStore(); @@ -297,7 +302,14 @@ describe("acp session UX bridge behavior", () => { const result = await agent.loadSession(createLoadSessionRequest("agent:main:work")); expect(result.modes?.currentModeId).toBe("high"); - expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("xhigh"); + expect(result.modes?.availableModes.map((mode) => mode.id)).toEqual([ + "off", + "minimal", + "low", + "medium", + "high", + "adaptive", + ]); expect(result.configOptions).toEqual( expect.arrayContaining([ expect.objectContaining({ 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..b138c4853d1 100644 --- a/src/agents/models-config.providers.discovery.ts +++ b/src/agents/models-config.providers.discovery.ts @@ -1,14 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ModelDefinitionConfig } from "../config/types.models.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js"; -import { - discoverHuggingfaceModels, - HUGGINGFACE_BASE_URL, - HUGGINGFACE_MODEL_CATALOG, - buildHuggingfaceModelDefinition, -} from "./huggingface-models.js"; -import { discoverKilocodeModels } from "./kilocode-models.js"; import { enrichOllamaModelsWithContext, OLLAMA_DEFAULT_CONTEXT_WINDOW, @@ -18,8 +10,17 @@ import { resolveOllamaApiBase, type OllamaTagsResponse, } from "./ollama-models.js"; -import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; -import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.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 { VLLM_DEFAULT_BASE_URL, VLLM_PROVIDER_LABEL } from "./vllm-defaults.js"; +export { buildHuggingfaceProvider } from "../../extensions/huggingface/provider-catalog.js"; +export { buildKilocodeProviderWithDiscovery } from "../../extensions/kilocode/provider-catalog.js"; +export { buildVeniceProvider } from "../../extensions/venice/provider-catalog.js"; +export { buildVercelAiGatewayProvider } from "../../extensions/vercel-ai-gateway/provider-catalog.js"; export { resolveOllamaApiBase } from "./ollama-models.js"; @@ -31,19 +32,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 +128,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) { @@ -151,15 +139,6 @@ async function discoverOpenAICompatibleLocalModels(params: { } } -export async function buildVeniceProvider(): Promise { - const models = await discoverVeniceModels(); - return { - baseUrl: VENICE_BASE_URL, - api: "openai-completions", - models, - }; -} - export async function buildOllamaProvider( configuredBaseUrl?: string, opts?: { quiet?: boolean }, @@ -172,36 +151,15 @@ export async function buildOllamaProvider( }; } -export async function buildHuggingfaceProvider(discoveryApiKey?: string): Promise { - const resolvedSecret = discoveryApiKey?.trim() ?? ""; - const models = - resolvedSecret !== "" - ? await discoverHuggingfaceModels(resolvedSecret) - : HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition); - return { - baseUrl: HUGGINGFACE_BASE_URL, - api: "openai-completions", - models, - }; -} - -export async function buildVercelAiGatewayProvider(): Promise { - return { - baseUrl: VERCEL_AI_GATEWAY_BASE_URL, - api: "anthropic-messages", - models: await discoverVercelAiGatewayModels(), - }; -} - 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 +172,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, @@ -226,16 +184,3 @@ export async function buildSglangProvider(params?: { models, }; } - -/** - * Build the Kilocode provider with dynamic model discovery from the gateway - * API. Falls back to the static catalog on failure. - */ -export async function buildKilocodeProviderWithDiscovery(): Promise { - const models = await discoverKilocodeModels(); - return { - baseUrl: KILOCODE_BASE_URL, - api: "openai-completions", - models, - }; -} diff --git a/src/agents/models-config.providers.static.ts b/src/agents/models-config.providers.static.ts index a0aa879c727..71184e12286 100644 --- a/src/agents/models-config.providers.static.ts +++ b/src/agents/models-config.providers.static.ts @@ -1,551 +1,35 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { - KILOCODE_BASE_URL, - KILOCODE_DEFAULT_CONTEXT_WINDOW, - KILOCODE_DEFAULT_COST, - KILOCODE_DEFAULT_MAX_TOKENS, - KILOCODE_MODEL_CATALOG, -} from "../providers/kilocode-shared.js"; -import { - buildBytePlusModelDefinition, - BYTEPLUS_BASE_URL, - BYTEPLUS_MODEL_CATALOG, - BYTEPLUS_CODING_BASE_URL, - BYTEPLUS_CODING_MODEL_CATALOG, -} from "./byteplus-models.js"; -import { - buildDoubaoModelDefinition, - DOUBAO_BASE_URL, - DOUBAO_MODEL_CATALOG, - DOUBAO_CODING_BASE_URL, - DOUBAO_CODING_MODEL_CATALOG, -} from "./doubao-models.js"; -import { - buildSyntheticModelDefinition, - SYNTHETIC_BASE_URL, - SYNTHETIC_MODEL_CATALOG, -} from "./synthetic-models.js"; -import { - TOGETHER_BASE_URL, - TOGETHER_MODEL_CATALOG, - buildTogetherModelDefinition, -} from "./together-models.js"; - -type ModelsConfig = NonNullable; -type ProviderConfig = NonNullable[string]; -type ProviderModelConfig = NonNullable[number]; - -const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic"; -const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5"; -const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; -const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; -const MINIMAX_DEFAULT_MAX_TOKENS = 8192; -const MINIMAX_API_COST = { - input: 0.3, - output: 1.2, - cacheRead: 0.03, - cacheWrite: 0.12, -}; - -function buildMinimaxModel(params: { - id: string; - name: string; - reasoning: boolean; - input: ProviderModelConfig["input"]; -}): ProviderModelConfig { - return { - id: params.id, - name: params.name, - reasoning: params.reasoning, - input: params.input, - cost: MINIMAX_API_COST, - contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, - maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, - }; -} - -function buildMinimaxTextModel(params: { - id: string; - name: string; - reasoning: boolean; -}): ProviderModelConfig { - return buildMinimaxModel({ ...params, input: ["text"] }); -} - -const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic"; -export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash"; -const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144; -const XIAOMI_DEFAULT_MAX_TOKENS = 8192; -const XIAOMI_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; -const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5"; -const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; -const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; -const MOONSHOT_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"; -const KIMI_CODING_USER_AGENT = "claude-code/0.1.0"; -const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5"; -const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144; -const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768; -const KIMI_CODING_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1"; -const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000; -const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192; -const QWEN_PORTAL_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; -const OPENROUTER_DEFAULT_MODEL_ID = "auto"; -const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000; -const OPENROUTER_DEFAULT_MAX_TOKENS = 8192; -const OPENROUTER_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2"; -export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2"; -const QIANFAN_DEFAULT_CONTEXT_WINDOW = 98304; -const QIANFAN_DEFAULT_MAX_TOKENS = 32768; -const QIANFAN_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -export const MODELSTUDIO_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; -export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus"; -const MODELSTUDIO_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const MODELSTUDIO_MODEL_CATALOG: ReadonlyArray = [ - { - id: "qwen3.5-plus", - name: "qwen3.5-plus", - reasoning: false, - input: ["text", "image"], - cost: MODELSTUDIO_DEFAULT_COST, - contextWindow: 1_000_000, - maxTokens: 65_536, - }, - { - id: "qwen3-max-2026-01-23", - name: "qwen3-max-2026-01-23", - reasoning: false, - input: ["text"], - cost: MODELSTUDIO_DEFAULT_COST, - contextWindow: 262_144, - maxTokens: 65_536, - }, - { - id: "qwen3-coder-next", - name: "qwen3-coder-next", - reasoning: false, - input: ["text"], - cost: MODELSTUDIO_DEFAULT_COST, - contextWindow: 262_144, - maxTokens: 65_536, - }, - { - id: "qwen3-coder-plus", - name: "qwen3-coder-plus", - reasoning: false, - input: ["text"], - cost: MODELSTUDIO_DEFAULT_COST, - contextWindow: 1_000_000, - maxTokens: 65_536, - }, - { - id: "MiniMax-M2.5", - name: "MiniMax-M2.5", - reasoning: true, - input: ["text"], - cost: MODELSTUDIO_DEFAULT_COST, - contextWindow: 1_000_000, - maxTokens: 65_536, - }, - { - id: "glm-5", - name: "glm-5", - reasoning: false, - input: ["text"], - cost: MODELSTUDIO_DEFAULT_COST, - contextWindow: 202_752, - maxTokens: 16_384, - }, - { - id: "glm-4.7", - name: "glm-4.7", - reasoning: false, - input: ["text"], - cost: MODELSTUDIO_DEFAULT_COST, - contextWindow: 202_752, - maxTokens: 16_384, - }, - { - id: "kimi-k2.5", - name: "kimi-k2.5", - reasoning: false, - input: ["text", "image"], - cost: MODELSTUDIO_DEFAULT_COST, - contextWindow: 262_144, - maxTokens: 32_768, - }, -]; - -const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; -const NVIDIA_DEFAULT_MODEL_ID = "nvidia/llama-3.1-nemotron-70b-instruct"; -const NVIDIA_DEFAULT_CONTEXT_WINDOW = 131072; -const NVIDIA_DEFAULT_MAX_TOKENS = 4096; -const NVIDIA_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; - -export function buildMinimaxProvider(): ProviderConfig { - return { - baseUrl: MINIMAX_PORTAL_BASE_URL, - api: "anthropic-messages", - authHeader: true, - models: [ - buildMinimaxModel({ - id: MINIMAX_DEFAULT_VISION_MODEL_ID, - name: "MiniMax VL 01", - reasoning: false, - input: ["text", "image"], - }), - buildMinimaxTextModel({ - id: "MiniMax-M2.5", - name: "MiniMax M2.5", - reasoning: true, - }), - buildMinimaxTextModel({ - id: "MiniMax-M2.5-highspeed", - name: "MiniMax M2.5 Highspeed", - reasoning: true, - }), - ], - }; -} - -export function buildMinimaxPortalProvider(): ProviderConfig { - return { - baseUrl: MINIMAX_PORTAL_BASE_URL, - api: "anthropic-messages", - authHeader: true, - models: [ - buildMinimaxModel({ - id: MINIMAX_DEFAULT_VISION_MODEL_ID, - name: "MiniMax VL 01", - reasoning: false, - input: ["text", "image"], - }), - buildMinimaxTextModel({ - id: MINIMAX_DEFAULT_MODEL_ID, - name: "MiniMax M2.5", - reasoning: true, - }), - buildMinimaxTextModel({ - id: "MiniMax-M2.5-highspeed", - name: "MiniMax M2.5 Highspeed", - reasoning: true, - }), - ], - }; -} - -export function buildMoonshotProvider(): ProviderConfig { - return { - baseUrl: MOONSHOT_BASE_URL, - api: "openai-completions", - models: [ - { - id: MOONSHOT_DEFAULT_MODEL_ID, - name: "Kimi K2.5", - reasoning: false, - input: ["text", "image"], - cost: MOONSHOT_DEFAULT_COST, - contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW, - maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS, - }, - ], - }; -} - -export function buildKimiCodingProvider(): ProviderConfig { - return { - baseUrl: KIMI_CODING_BASE_URL, - api: "anthropic-messages", - headers: { - "User-Agent": KIMI_CODING_USER_AGENT, - }, - models: [ - { - id: KIMI_CODING_DEFAULT_MODEL_ID, - name: "Kimi for Coding", - reasoning: true, - input: ["text", "image"], - cost: KIMI_CODING_DEFAULT_COST, - contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW, - maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS, - }, - ], - }; -} - -export function buildQwenPortalProvider(): ProviderConfig { - return { - baseUrl: QWEN_PORTAL_BASE_URL, - api: "openai-completions", - models: [ - { - id: "coder-model", - name: "Qwen Coder", - reasoning: false, - input: ["text"], - cost: QWEN_PORTAL_DEFAULT_COST, - contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW, - maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS, - }, - { - id: "vision-model", - name: "Qwen Vision", - reasoning: false, - input: ["text", "image"], - cost: QWEN_PORTAL_DEFAULT_COST, - contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW, - maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS, - }, - ], - }; -} - -export function buildSyntheticProvider(): ProviderConfig { - return { - baseUrl: SYNTHETIC_BASE_URL, - api: "anthropic-messages", - models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition), - }; -} - -export function buildDoubaoProvider(): ProviderConfig { - return { - baseUrl: DOUBAO_BASE_URL, - api: "openai-completions", - models: DOUBAO_MODEL_CATALOG.map(buildDoubaoModelDefinition), - }; -} - -export function buildDoubaoCodingProvider(): ProviderConfig { - return { - baseUrl: DOUBAO_CODING_BASE_URL, - api: "openai-completions", - models: DOUBAO_CODING_MODEL_CATALOG.map(buildDoubaoModelDefinition), - }; -} - -export function buildBytePlusProvider(): ProviderConfig { - return { - baseUrl: BYTEPLUS_BASE_URL, - api: "openai-completions", - models: BYTEPLUS_MODEL_CATALOG.map(buildBytePlusModelDefinition), - }; -} - -export function buildBytePlusCodingProvider(): ProviderConfig { - return { - baseUrl: BYTEPLUS_CODING_BASE_URL, - api: "openai-completions", - models: BYTEPLUS_CODING_MODEL_CATALOG.map(buildBytePlusModelDefinition), - }; -} - -export function buildXiaomiProvider(): ProviderConfig { - return { - baseUrl: XIAOMI_BASE_URL, - api: "anthropic-messages", - models: [ - { - id: XIAOMI_DEFAULT_MODEL_ID, - name: "Xiaomi MiMo V2 Flash", - reasoning: false, - input: ["text"], - cost: XIAOMI_DEFAULT_COST, - contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW, - maxTokens: XIAOMI_DEFAULT_MAX_TOKENS, - }, - ], - }; -} - -export function buildTogetherProvider(): ProviderConfig { - return { - baseUrl: TOGETHER_BASE_URL, - api: "openai-completions", - models: TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition), - }; -} - -export function buildOpenrouterProvider(): ProviderConfig { - return { - baseUrl: OPENROUTER_BASE_URL, - api: "openai-completions", - models: [ - { - id: OPENROUTER_DEFAULT_MODEL_ID, - name: "OpenRouter Auto", - reasoning: false, - input: ["text", "image"], - cost: OPENROUTER_DEFAULT_COST, - contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW, - maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS, - }, - { - id: "openrouter/hunter-alpha", - name: "Hunter Alpha", - reasoning: true, - input: ["text"], - cost: OPENROUTER_DEFAULT_COST, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "openrouter/healer-alpha", - name: "Healer Alpha", - reasoning: true, - input: ["text", "image"], - cost: OPENROUTER_DEFAULT_COST, - contextWindow: 262144, - maxTokens: 65536, - }, - ], - }; -} - -export function buildOpenAICodexProvider(): ProviderConfig { - return { - baseUrl: OPENAI_CODEX_BASE_URL, - api: "openai-codex-responses", - models: [], - }; -} - -export function buildQianfanProvider(): ProviderConfig { - return { - baseUrl: QIANFAN_BASE_URL, - api: "openai-completions", - models: [ - { - id: QIANFAN_DEFAULT_MODEL_ID, - name: "DEEPSEEK V3.2", - reasoning: true, - input: ["text"], - cost: QIANFAN_DEFAULT_COST, - contextWindow: QIANFAN_DEFAULT_CONTEXT_WINDOW, - maxTokens: QIANFAN_DEFAULT_MAX_TOKENS, - }, - { - id: "ernie-5.0-thinking-preview", - name: "ERNIE-5.0-Thinking-Preview", - reasoning: true, - input: ["text", "image"], - cost: QIANFAN_DEFAULT_COST, - contextWindow: 119000, - maxTokens: 64000, - }, - ], - }; -} - -export function buildModelStudioProvider(): ProviderConfig { - return { - baseUrl: MODELSTUDIO_BASE_URL, - api: "openai-completions", - models: MODELSTUDIO_MODEL_CATALOG.map((model) => ({ ...model })), - }; -} - -export function buildNvidiaProvider(): ProviderConfig { - return { - baseUrl: NVIDIA_BASE_URL, - api: "openai-completions", - models: [ - { - id: NVIDIA_DEFAULT_MODEL_ID, - name: "NVIDIA Llama 3.1 Nemotron 70B Instruct", - reasoning: false, - input: ["text"], - cost: NVIDIA_DEFAULT_COST, - contextWindow: NVIDIA_DEFAULT_CONTEXT_WINDOW, - maxTokens: NVIDIA_DEFAULT_MAX_TOKENS, - }, - { - id: "meta/llama-3.3-70b-instruct", - name: "Meta Llama 3.3 70B Instruct", - reasoning: false, - input: ["text"], - cost: NVIDIA_DEFAULT_COST, - contextWindow: 131072, - maxTokens: 4096, - }, - { - id: "nvidia/mistral-nemo-minitron-8b-8k-instruct", - name: "NVIDIA Mistral NeMo Minitron 8B Instruct", - reasoning: false, - input: ["text"], - cost: NVIDIA_DEFAULT_COST, - contextWindow: 8192, - maxTokens: 2048, - }, - ], - }; -} - -export function buildKilocodeProvider(): ProviderConfig { - return { - baseUrl: KILOCODE_BASE_URL, - api: "openai-completions", - models: KILOCODE_MODEL_CATALOG.map((model) => ({ - id: model.id, - name: model.name, - reasoning: model.reasoning, - input: model.input, - cost: KILOCODE_DEFAULT_COST, - contextWindow: model.contextWindow ?? KILOCODE_DEFAULT_CONTEXT_WINDOW, - maxTokens: model.maxTokens ?? KILOCODE_DEFAULT_MAX_TOKENS, - })), - }; -} +export { + buildBytePlusCodingProvider, + buildBytePlusProvider, +} from "../../extensions/byteplus/provider-catalog.js"; +export { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js"; +export { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js"; +export { + buildMinimaxPortalProvider, + buildMinimaxProvider, +} from "../../extensions/minimax/provider-catalog.js"; +export { + MODELSTUDIO_BASE_URL, + MODELSTUDIO_DEFAULT_MODEL_ID, + buildModelStudioProvider, +} from "../../extensions/modelstudio/provider-catalog.js"; +export { buildMoonshotProvider } from "../../extensions/moonshot/provider-catalog.js"; +export { buildNvidiaProvider } from "../../extensions/nvidia/provider-catalog.js"; +export { buildOpenAICodexProvider } from "../../extensions/openai/openai-codex-catalog.js"; +export { buildOpenrouterProvider } from "../../extensions/openrouter/provider-catalog.js"; +export { + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, + buildQianfanProvider, +} from "../../extensions/qianfan/provider-catalog.js"; +export { buildQwenPortalProvider } from "../../extensions/qwen-portal-auth/provider-catalog.js"; +export { buildSyntheticProvider } from "../../extensions/synthetic/provider-catalog.js"; +export { buildTogetherProvider } from "../../extensions/together/provider-catalog.js"; +export { + buildDoubaoCodingProvider, + buildDoubaoProvider, +} from "../../extensions/volcengine/provider-catalog.js"; +export { + XIAOMI_DEFAULT_MODEL_ID, + buildXiaomiProvider, +} from "../../extensions/xiaomi/provider-catalog.js"; 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/openclaw-tools.plugin-context.test.ts b/src/agents/openclaw-tools.plugin-context.test.ts index 1cf9116a98e..6a20a127898 100644 --- a/src/agents/openclaw-tools.plugin-context.test.ts +++ b/src/agents/openclaw-tools.plugin-context.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const { resolvePluginToolsMock } = vi.hoisted(() => ({ resolvePluginToolsMock: vi.fn((params?: unknown) => { @@ -9,11 +9,17 @@ const { resolvePluginToolsMock } = vi.hoisted(() => ({ vi.mock("../plugins/tools.js", () => ({ resolvePluginTools: resolvePluginToolsMock, + getPluginToolMeta: vi.fn(() => undefined), })); import { createOpenClawTools } from "./openclaw-tools.js"; +import { createOpenClawCodingTools } from "./pi-tools.js"; describe("createOpenClawTools plugin context", () => { + beforeEach(() => { + resolvePluginToolsMock.mockClear(); + }); + it("forwards trusted requester sender identity to plugin tool context", () => { createOpenClawTools({ config: {} as never, @@ -47,4 +53,30 @@ describe("createOpenClawTools plugin context", () => { }), ); }); + + it("forwards gateway subagent binding for plugin tools", () => { + createOpenClawTools({ + config: {} as never, + allowGatewaySubagentBinding: true, + }); + + expect(resolvePluginToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + allowGatewaySubagentBinding: true, + }), + ); + }); + + it("forwards gateway subagent binding through coding tools", () => { + createOpenClawCodingTools({ + config: {} as never, + allowGatewaySubagentBinding: true, + }); + + expect(resolvePluginToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + allowGatewaySubagentBinding: true, + }), + ); + }); }); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 25b5cae0f59..32bd92f4207 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -80,6 +80,8 @@ export function createOpenClawTools( spawnWorkspaceDir?: string; /** Callback invoked when sessions_yield tool is called. */ onYield?: (message: string) => Promise | void; + /** Allow plugin tools for this tool set to late-bind the gateway subagent. */ + allowGatewaySubagentBinding?: boolean; } & SpawnedToolContext, ): AnyAgentTool[] { const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir); @@ -235,6 +237,7 @@ export function createOpenClawTools( }, existingToolNames: new Set(tools.map((tool) => tool.name)), toolAllowlist: options?.pluginToolAllowlist, + allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding, }); return [...tools, ...pluginTools]; diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 397445067c1..8fc8ac1fddc 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -35,6 +35,12 @@ describe("formatAssistantErrorText", () => { ); expect(formatAssistantErrorText(msg)).toContain("Context overflow"); }); + it("returns context overflow for Ollama 'prompt too long' errors (#34005)", () => { + const msg = makeAssistantError( + 'Ollama API error 400: {"StatusCode":400,"Status":"400 Bad Request","error":"prompt too long; exceeded max context length by 4 tokens"}', + ); + expect(formatAssistantErrorText(msg)).toContain("Context overflow"); + }); it("returns a reasoning-required message for mandatory reasoning endpoint errors", () => { const msg = makeAssistantError( "400 Reasoning is mandatory for this endpoint and cannot be disabled.", diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts index 33c85b832e5..2808d320cc5 100644 --- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts +++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts @@ -42,6 +42,14 @@ describe("sanitizeUserFacingText", () => { ); }); + it("sanitizes Ollama prompt-too-long payloads through the context-overflow path", () => { + const text = + 'Ollama API error 400: {"StatusCode":400,"Status":"400 Bad Request","error":"prompt too long; exceeded max context length by 4 tokens"}'; + expect(sanitizeUserFacingText(text, { errorContext: true })).toContain( + "Context overflow: prompt too large for the model.", + ); + }); + it.each([ "Changelog note: we fixed false positives for `Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.` in 2026.2.9", "nah it failed, hit a context overflow. the prompt was too large for the model. want me to retry it with a different approach?", diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 6e38d831ad9..605cdd22118 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -97,6 +97,7 @@ export function isContextOverflowError(errorMessage?: string): boolean { lower.includes("context length exceeded") || lower.includes("maximum context length") || lower.includes("prompt is too long") || + lower.includes("prompt too long") || lower.includes("exceeds model context window") || lower.includes("model token limit") || (hasRequestSizeExceeds && hasContextWindow) || @@ -211,11 +212,12 @@ export function extractObservedOverflowTokenCount(errorMessage?: string): number return undefined; } +// Allow provider-wrapped API payloads such as "Ollama API error 400: {...}". const ERROR_PAYLOAD_PREFIX_RE = - /^(?:error|api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)[:\s-]+/i; + /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)(?:\s+\d{3})?[:\s-]+/i; const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi; const ERROR_PREFIX_RE = - /^(?:error|api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)[:\s-]+/i; + /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i; const CONTEXT_OVERFLOW_ERROR_HEAD_RE = /^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i; const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 0a864236b81..54ad50539e3 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -15,6 +15,7 @@ const { resolveSessionAgentIdMock, estimateTokensMock, sessionAbortCompactionMock, + createOpenClawCodingToolsMock, } = vi.hoisted(() => { const contextEngineCompactMock = vi.fn(async () => ({ ok: true as boolean, @@ -36,12 +37,14 @@ const { info: { ownsCompaction: true }, compact: contextEngineCompactMock, })), - resolveModelMock: vi.fn(() => ({ - model: { provider: "openai", api: "responses", id: "fake", input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })), + resolveModelMock: vi.fn( + (_provider?: string, _modelId?: string, _agentDir?: string, _cfg?: unknown) => ({ + model: { provider: "openai", api: "responses", id: "fake", input: [] }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + }), + ), sessionCompactImpl: vi.fn(async () => ({ summary: "summary", firstKeptEntryId: "entry-1", @@ -67,6 +70,7 @@ const { resolveSessionAgentIdMock: vi.fn(() => "main"), estimateTokensMock: vi.fn((_message?: unknown) => 10), sessionAbortCompactionMock: vi.fn(), + createOpenClawCodingToolsMock: vi.fn(() => []), }; }); @@ -205,7 +209,7 @@ vi.mock("../channel-tools.js", () => ({ })); vi.mock("../pi-tools.js", () => ({ - createOpenClawCodingTools: vi.fn(() => []), + createOpenClawCodingTools: createOpenClawCodingToolsMock, })); vi.mock("./google.js", () => ({ @@ -307,6 +311,10 @@ vi.mock("./sandbox-info.js", () => ({ vi.mock("./model.js", () => ({ buildModelAliasLines: vi.fn(() => []), resolveModel: resolveModelMock, + resolveModelAsync: vi.fn( + async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => + resolveModelMock(provider, modelId, agentDir, cfg), + ), })); vi.mock("./session-manager-cache.js", () => ({ @@ -449,6 +457,26 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); }); + it("forwards gateway subagent binding opt-in during compaction bootstrap", async () => { + await compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp/workspace", + allowGatewaySubagentBinding: true, + }); + + expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + config: undefined, + workspaceDir: "/tmp/workspace", + allowGatewaySubagentBinding: true, + }); + expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + allowGatewaySubagentBinding: true, + }), + ); + }); + it("emits internal + plugin compaction hooks with counts", async () => { hookRunner.hasHooks.mockReturnValue(true); let sanitizedCount = 0; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 67c5b8184b2..ba001a6746a 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -147,6 +147,8 @@ export type CompactEmbeddedPiSessionParams = { extraSystemPrompt?: string; ownerNumbers?: string[]; abortSignal?: AbortSignal; + /** Allow runtime plugins for this compaction to late-bind the gateway subagent. */ + allowGatewaySubagentBinding?: boolean; }; type CompactionMessageMetrics = { @@ -384,6 +386,7 @@ export async function compactEmbeddedPiSessionDirect( ensureRuntimePluginsLoaded({ config: params.config, workspaceDir: resolvedWorkspace, + allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, }); const prevCwd = process.cwd(); @@ -570,6 +573,7 @@ export async function compactEmbeddedPiSessionDirect( groupSpace: params.groupSpace, spawnedBy: params.spawnedBy, senderIsOwner: params.senderIsOwner, + allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, agentDir, workspaceDir: effectiveWorkspace, config: params.config, @@ -1086,6 +1090,7 @@ export async function compactEmbeddedPiSession( ensureRuntimePluginsLoaded({ config: params.config, workspaceDir: params.workspaceDir, + allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, }); ensureContextEnginesInitialized(); const contextEngine = await resolveContextEngine(params.config); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts index 53e73e6246d..8451ef54994 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts @@ -156,6 +156,19 @@ vi.mock("./model.js", () => ({ }, modelRegistry: {}, })), + resolveModelAsync: vi.fn(async () => ({ + model: { + id: "test-model", + provider: "anthropic", + contextWindow: 200000, + api: "messages", + }, + error: null, + authStorage: { + setRuntimeApiKey: vi.fn(), + }, + modelRegistry: {}, + })), })); vi.mock("../model-auth.js", () => ({ diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 6ecf34ed93e..3f41357f0e5 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -302,6 +302,7 @@ export async function runEmbeddedPiAgent( ensureRuntimePluginsLoaded({ config: params.config, workspaceDir: resolvedWorkspace, + allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, }); const prevCwd = process.cwd(); @@ -952,6 +953,7 @@ export async function runEmbeddedPiAgent( workspaceDir: resolvedWorkspace, agentDir, config: params.config, + allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, contextEngine, contextTokenBudget: ctxInfo.tokens, skillsSnapshot: params.skillsSnapshot, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 3a27ba409b4..7acde5472f3 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1509,6 +1509,7 @@ export async function runEmbeddedAttempt( senderUsername: params.senderUsername, senderE164: params.senderE164, senderIsOwner: params.senderIsOwner, + allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, sessionKey: sandboxSessionKey, sessionId: params.sessionId, runId: params.runId, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index ba69d991dd9..3aef4fb2752 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -63,6 +63,8 @@ export type RunEmbeddedPiAgentParams = { requireExplicitMessageTarget?: boolean; /** If true, omit the message tool from the tool list. */ disableMessageTool?: boolean; + /** Allow runtime plugins for this run to late-bind the gateway subagent. */ + allowGatewaySubagentBinding?: boolean; sessionFile: string; workspaceDir: string; agentDir?: string; diff --git a/src/agents/pi-embedded-runner/tool-result-context-guard.test.ts b/src/agents/pi-embedded-runner/tool-result-context-guard.test.ts index df50558e951..9f265d3b56e 100644 --- a/src/agents/pi-embedded-runner/tool-result-context-guard.test.ts +++ b/src/agents/pi-embedded-runner/tool-result-context-guard.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js"; import { CONTEXT_LIMIT_TRUNCATION_NOTICE, + PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE, PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER, installToolResultContextGuard, } from "./tool-result-context-guard.js"; @@ -268,4 +269,63 @@ describe("installToolResultContextGuard", () => { expect(oldResult.details).toBeUndefined(); expect(newResult.details).toBeUndefined(); }); + + it("throws preemptive context overflow when context exceeds 90% after tool-result compaction", async () => { + const agent = makeGuardableAgent(); + + installToolResultContextGuard({ + agent, + // contextBudgetChars = 1000 * 4 * 0.75 = 3000 + // preemptiveOverflowChars = 1000 * 4 * 0.9 = 3600 + contextWindowTokens: 1_000, + }); + + // Large user message (non-compactable) pushes context past 90% threshold. + const contextForNextCall = [makeUser("u".repeat(3_700)), makeToolResult("call_1", "small")]; + + await expect( + agent.transformContext?.(contextForNextCall, new AbortController().signal), + ).rejects.toThrow(PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE); + }); + + it("does not throw when context is under 90% after tool-result compaction", async () => { + const agent = makeGuardableAgent(); + + installToolResultContextGuard({ + agent, + contextWindowTokens: 1_000, + }); + + // Context well under the 3600-char preemptive threshold. + const contextForNextCall = [makeUser("u".repeat(1_000)), makeToolResult("call_1", "small")]; + + await expect( + agent.transformContext?.(contextForNextCall, new AbortController().signal), + ).resolves.not.toThrow(); + }); + + it("compacts tool results before checking the preemptive overflow threshold", async () => { + const agent = makeGuardableAgent(); + + installToolResultContextGuard({ + agent, + contextWindowTokens: 1_000, + }); + + // Large user message + large tool result. The guard should compact the tool + // result first, then check the overflow threshold. Even after compaction the + // user content alone pushes past 90%, so the overflow error fires. + const contextForNextCall = [ + makeUser("u".repeat(3_700)), + makeToolResult("call_old", "x".repeat(2_000)), + ]; + + await expect( + agent.transformContext?.(contextForNextCall, new AbortController().signal), + ).rejects.toThrow(PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE); + + // Tool result should have been compacted before the overflow check. + const toolResultText = getToolResultText(contextForNextCall[1]); + expect(toolResultText).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER); + }); }); diff --git a/src/agents/pi-embedded-runner/tool-result-context-guard.ts b/src/agents/pi-embedded-runner/tool-result-context-guard.ts index 4a3d3482421..1ab23ede3cf 100644 --- a/src/agents/pi-embedded-runner/tool-result-context-guard.ts +++ b/src/agents/pi-embedded-runner/tool-result-context-guard.ts @@ -14,6 +14,9 @@ import { // Keep a conservative input budget to absorb tokenizer variance and provider framing overhead. const CONTEXT_INPUT_HEADROOM_RATIO = 0.75; const SINGLE_TOOL_RESULT_CONTEXT_SHARE = 0.5; +// High-water mark: if context exceeds this ratio after tool-result compaction, +// trigger full session compaction via the existing overflow recovery cascade. +const PREEMPTIVE_OVERFLOW_RATIO = 0.9; export const CONTEXT_LIMIT_TRUNCATION_NOTICE = "[truncated: output exceeded context limit]"; const CONTEXT_LIMIT_TRUNCATION_SUFFIX = `\n${CONTEXT_LIMIT_TRUNCATION_NOTICE}`; @@ -21,6 +24,9 @@ const CONTEXT_LIMIT_TRUNCATION_SUFFIX = `\n${CONTEXT_LIMIT_TRUNCATION_NOTICE}`; export const PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER = "[compacted: tool output removed to free context]"; +export const PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE = + "Preemptive context overflow: estimated context size exceeds safe threshold during tool loop"; + type GuardableTransformContext = ( messages: AgentMessage[], signal: AbortSignal, @@ -196,6 +202,10 @@ export function installToolResultContextGuard(params: { contextWindowTokens * TOOL_RESULT_CHARS_PER_TOKEN_ESTIMATE * SINGLE_TOOL_RESULT_CONTEXT_SHARE, ), ); + const preemptiveOverflowChars = Math.max( + contextBudgetChars, + Math.floor(contextWindowTokens * CHARS_PER_TOKEN_ESTIMATE * PREEMPTIVE_OVERFLOW_RATIO), + ); // Agent.transformContext is private in pi-coding-agent, so access it via a // narrow runtime view to keep callsites type-safe while preserving behavior. @@ -214,6 +224,18 @@ export function installToolResultContextGuard(params: { maxSingleToolResultChars, }); + // After tool-result compaction, check if context still exceeds the high-water mark. + // If it does, non-tool-result content dominates and only full LLM-based session + // compaction can reduce context size. Throwing a context overflow error triggers + // the existing overflow recovery cascade in run.ts. + const postEnforcementChars = estimateContextChars( + contextMessages, + createMessageCharEstimateCache(), + ); + if (postEnforcementChars > preemptiveOverflowChars) { + throw new Error(PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE); + } + return contextMessages; }) as GuardableTransformContext; diff --git a/src/agents/pi-embedded-runner/usage-reporting.test.ts b/src/agents/pi-embedded-runner/usage-reporting.test.ts index ebab56a841b..7c29c5f99cf 100644 --- a/src/agents/pi-embedded-runner/usage-reporting.test.ts +++ b/src/agents/pi-embedded-runner/usage-reporting.test.ts @@ -45,6 +45,39 @@ describe("runEmbeddedPiAgent usage reporting", () => { }); }); + it("forwards gateway subagent binding opt-in to runtime plugin bootstrap", async () => { + mockedRunEmbeddedAttempt.mockResolvedValueOnce({ + aborted: false, + promptError: null, + timedOut: false, + sessionIdUsed: "test-session", + assistantTexts: ["Response 1"], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await runEmbeddedPiAgent({ + sessionId: "test-session", + sessionKey: "test-key", + sessionFile: "/tmp/session.json", + workspaceDir: "/tmp/workspace", + prompt: "hello", + timeoutMs: 30000, + runId: "run-gateway-bind", + allowGatewaySubagentBinding: true, + }); + + expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + config: undefined, + workspaceDir: "/tmp/workspace", + allowGatewaySubagentBinding: true, + }); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + allowGatewaySubagentBinding: true, + }), + ); + }); + it("forwards sender identity fields into embedded attempts", async () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce({ aborted: false, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 9c7aafbd56e..b8be63f65e5 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -259,6 +259,8 @@ export function createOpenClawCodingTools(options?: { replyToMode?: "off" | "first" | "all"; /** Mutable ref to track if a reply was sent (for "first" mode). */ hasRepliedRef?: { value: boolean }; + /** Allow plugin tools for this run to late-bind the gateway subagent. */ + allowGatewaySubagentBinding?: boolean; /** If true, the model has native vision capability */ modelHasVision?: boolean; /** Require explicit message targets (no implicit last-route sends). */ @@ -535,6 +537,7 @@ export function createOpenClawCodingTools(options?: { senderIsOwner: options?.senderIsOwner, sessionId: options?.sessionId, onYield: options?.onYield, + allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding, }), ]; const toolsForMemoryFlush = 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/runtime-plugins.ts b/src/agents/runtime-plugins.ts index ace53258e0f..0bf395b505c 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -5,6 +5,7 @@ import { resolveUserPath } from "../utils.js"; export function ensureRuntimePluginsLoaded(params: { config?: OpenClawConfig; workspaceDir?: string | null; + allowGatewaySubagentBinding?: boolean; }): void { const workspaceDir = typeof params.workspaceDir === "string" && params.workspaceDir.trim() @@ -14,5 +15,10 @@ export function ensureRuntimePluginsLoaded(params: { loadOpenClawPlugins({ config: params.config, workspaceDir, + runtimeOptions: params.allowGatewaySubagentBinding + ? { + allowGatewaySubagentBinding: true, + } + : undefined, }); } 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/subagent-registry.context-engine.test.ts b/src/agents/subagent-registry.context-engine.test.ts index 59eea1bd4c7..bb9916b7cfd 100644 --- a/src/agents/subagent-registry.context-engine.test.ts +++ b/src/agents/subagent-registry.context-engine.test.ts @@ -79,6 +79,7 @@ describe("subagent-registry context-engine bootstrap", () => { expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ config: {}, workspaceDir: "/tmp/workspace", + allowGatewaySubagentBinding: true, }); }); expect(mocks.ensureContextEnginesInitialized).toHaveBeenCalledTimes(1); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index c1cab60dd82..d36e20bf291 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -322,6 +322,7 @@ async function notifyContextEngineSubagentEnded(params: { ensureRuntimePluginsLoaded({ config: cfg, workspaceDir: params.workspaceDir, + allowGatewaySubagentBinding: true, }); ensureContextEnginesInitialized(); const engine = await resolveContextEngine(cfg); 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/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 9ebc239f7ff..5c9b78c208f 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -323,6 +323,7 @@ export async function runAgentTurnWithFallback(params: { try { const result = await runEmbeddedPiAgent({ ...embeddedContext, + allowGatewaySubagentBinding: true, trigger: params.isHeartbeat ? "heartbeat" : "user", groupId: resolveGroupSessionKey(params.sessionCtx)?.id, groupChannel: diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index d52c6d05761..267326a7e20 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -494,6 +494,7 @@ export async function runMemoryFlushIfNeeded(params: { ...embeddedContext, ...senderContext, ...runBaseParams, + allowGatewaySubagentBinding: true, trigger: "memory", memoryFlushWritePath, prompt: resolveMemoryFlushPromptForRun({ diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index 1533bb24393..9c3c9f28c29 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -78,6 +78,7 @@ export const handleCompactCommand: CommandHandler = async (params) => { const result = await compactEmbeddedPiSession({ sessionId, sessionKey: params.sessionKey, + allowGatewaySubagentBinding: true, messageChannel: params.command.channel, groupId: params.sessionEntry.groupId, groupChannel: params.sessionEntry.groupChannel, diff --git a/src/auto-reply/reply/commands-system-prompt.test.ts b/src/auto-reply/reply/commands-system-prompt.test.ts new file mode 100644 index 00000000000..09499fc3181 --- /dev/null +++ b/src/auto-reply/reply/commands-system-prompt.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HandleCommandsParams } from "./commands-types.js"; + +const { createOpenClawCodingToolsMock } = vi.hoisted(() => ({ + createOpenClawCodingToolsMock: vi.fn(() => []), +})); + +vi.mock("../../agents/bootstrap-files.js", () => ({ + resolveBootstrapContextForRun: vi.fn(async () => ({ + bootstrapFiles: [], + contextFiles: [], + })), +})); + +vi.mock("../../agents/pi-tools.js", () => ({ + createOpenClawCodingTools: createOpenClawCodingToolsMock, +})); + +vi.mock("../../agents/sandbox.js", () => ({ + resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false, mode: "off" })), +})); + +vi.mock("../../agents/skills.js", () => ({ + buildWorkspaceSkillSnapshot: vi.fn(() => ({ prompt: "", skills: [], resolvedSkills: [] })), +})); + +vi.mock("../../agents/skills/refresh.js", () => ({ + getSkillsSnapshotVersion: vi.fn(() => "test-snapshot"), +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveSessionAgentIds: vi.fn(() => ({ sessionAgentId: "main" })), +})); + +vi.mock("../../agents/model-selection.js", () => ({ + resolveDefaultModelForAgent: vi.fn(() => ({ provider: "openai", model: "gpt-5" })), +})); + +vi.mock("../../agents/system-prompt-params.js", () => ({ + buildSystemPromptParams: vi.fn(() => ({ + runtimeInfo: { host: "unknown", os: "unknown", arch: "unknown", node: process.version }, + userTimezone: "UTC", + userTime: "12:00 PM", + userTimeFormat: "12h", + })), +})); + +vi.mock("../../agents/system-prompt.js", () => ({ + buildAgentSystemPrompt: vi.fn(() => "system prompt"), +})); + +vi.mock("../../agents/tool-summaries.js", () => ({ + buildToolSummaryMap: vi.fn(() => ({})), +})); + +vi.mock("../../infra/skills-remote.js", () => ({ + getRemoteSkillEligibility: vi.fn(() => false), +})); + +vi.mock("../../tts/tts.js", () => ({ + buildTtsSystemPromptHint: vi.fn(() => undefined), +})); + +import { resolveCommandsSystemPromptBundle } from "./commands-system-prompt.js"; + +function makeParams(): HandleCommandsParams { + return { + ctx: { + SessionKey: "agent:main:default", + }, + cfg: {}, + command: { + surface: "telegram", + channel: "telegram", + ownerList: [], + senderIsOwner: true, + isAuthorizedSender: true, + rawBodyNormalized: "/context", + commandBodyNormalized: "/context", + }, + directives: {}, + elevated: { + enabled: true, + allowed: true, + failures: [], + }, + agentId: "main", + sessionEntry: { + sessionId: "session-1", + groupId: "group-1", + groupChannel: "#general", + space: "guild-1", + spawnedBy: "agent:parent", + }, + sessionKey: "agent:main:default", + workspaceDir: "/tmp/workspace", + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + provider: "openai", + model: "gpt-5.4", + contextTokens: 0, + isGroup: false, + } as unknown as HandleCommandsParams; +} + +describe("resolveCommandsSystemPromptBundle", () => { + beforeEach(() => { + createOpenClawCodingToolsMock.mockClear(); + createOpenClawCodingToolsMock.mockReturnValue([]); + }); + + it("opts command tool builds into gateway subagent binding", async () => { + await resolveCommandsSystemPromptBundle(makeParams()); + + expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + allowGatewaySubagentBinding: true, + sessionKey: "agent:main:default", + workspaceDir: "/tmp/workspace", + messageProvider: "telegram", + }), + ); + }); +}); diff --git a/src/auto-reply/reply/commands-system-prompt.ts b/src/auto-reply/reply/commands-system-prompt.ts index 4197e7b2491..18b2e337d72 100644 --- a/src/auto-reply/reply/commands-system-prompt.ts +++ b/src/auto-reply/reply/commands-system-prompt.ts @@ -57,6 +57,7 @@ export async function resolveCommandsSystemPromptBundle( agentId: params.agentId, workspaceDir, sessionKey: params.sessionKey, + allowGatewaySubagentBinding: true, messageProvider: params.command.channel, groupId: params.sessionEntry?.groupId ?? undefined, groupChannel: params.sessionEntry?.groupChannel ?? undefined, diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts index a6711d2c643..e635b038831 100644 --- a/src/auto-reply/reply/commands-tts.ts +++ b/src/auto-reply/reply/commands-tts.ts @@ -1,4 +1,5 @@ import { logVerbose } from "../../globals.js"; +import { listSpeechProviders, normalizeSpeechProviderId } from "../../tts/provider-registry.js"; import { getLastTtsAttempt, getTtsMaxLength, @@ -54,7 +55,7 @@ function ttsUsage(): ReplyPayload { `• /tts summary [on|off] — View/change auto-summary\n` + `• /tts audio — Generate audio from text\n\n` + `**Providers:**\n` + - `• edge — Free, fast (default)\n` + + `• microsoft — Microsoft Edge-backed speech (default fallback)\n` + `• openai — High quality (requires API key)\n` + `• elevenlabs — Premium voices (requires API key)\n\n` + `**Text Limit (default: 1500, max: 4096):**\n` + @@ -62,7 +63,7 @@ function ttsUsage(): ReplyPayload { `• Summary ON: AI summarizes, then generates audio\n` + `• Summary OFF: Truncates text, then generates audio\n\n` + `**Examples:**\n` + - `/tts provider edge\n` + + `/tts provider microsoft\n` + `/tts limit 2000\n` + `/tts audio Hello, this is a test!`, }; @@ -161,7 +162,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand if (!args.trim()) { const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai")); const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs")); - const hasEdge = isTtsProviderConfigured(config, "edge"); + const hasMicrosoft = isTtsProviderConfigured(config, "microsoft", params.cfg); return { shouldContinue: false, reply: { @@ -170,21 +171,23 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand `Primary: ${currentProvider}\n` + `OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` + `ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` + - `Edge enabled: ${hasEdge ? "✅" : "❌"}\n` + - `Usage: /tts provider openai | elevenlabs | edge`, + `Microsoft enabled: ${hasMicrosoft ? "✅" : "❌"}\n` + + `Usage: /tts provider openai | elevenlabs | microsoft`, }, }; } const requested = args.trim().toLowerCase(); - if (requested !== "openai" && requested !== "elevenlabs" && requested !== "edge") { + const knownProviders = new Set(listSpeechProviders(params.cfg).map((provider) => provider.id)); + if (requested !== "edge" && !knownProviders.has(requested)) { return { shouldContinue: false, reply: ttsUsage() }; } + const nextProvider = normalizeSpeechProviderId(requested) ?? requested; setTtsProvider(prefsPath, requested); return { shouldContinue: false, - reply: { text: `✅ TTS provider set to ${requested}.` }, + reply: { text: `✅ TTS provider set to ${nextProvider}.` }, }; } @@ -249,7 +252,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand if (action === "status") { const enabled = isTtsEnabled(config, prefsPath); const provider = getTtsProvider(config, prefsPath); - const hasKey = isTtsProviderConfigured(config, provider); + const hasKey = isTtsProviderConfigured(config, provider, params.cfg); const maxLength = getTtsMaxLength(prefsPath); const summarize = isSummarizationEnabled(prefsPath); const last = getLastTtsAttempt(); diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 0f2853aab98..5ed9919b7e8 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -599,6 +599,7 @@ describe("/compact command", () => { expect.objectContaining({ sessionId: "session-1", sessionKey: "agent:main:main", + allowGatewaySubagentBinding: true, trigger: "manual", customInstructions: "focus on decisions", messageChannel: "whatsapp", diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index c8e33397a2a..fa7f0fb8637 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -287,10 +287,12 @@ describe("createFollowupRunner bootstrap warning dedupe", () => { const call = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0] as | { + allowGatewaySubagentBinding?: boolean; bootstrapPromptWarningSignaturesSeen?: string[]; bootstrapPromptWarningSignature?: string; } | undefined; + expect(call?.allowGatewaySubagentBinding).toBe(true); expect(call?.bootstrapPromptWarningSignaturesSeen).toEqual(["sig-a", "sig-b"]); expect(call?.bootstrapPromptWarningSignature).toBe("sig-b"); }); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index fe90d56433c..339883e730b 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -171,6 +171,7 @@ export function createFollowupRunner(params: { let attemptCompactionCount = 0; try { const result = await runEmbeddedPiAgent({ + allowGatewaySubagentBinding: true, sessionId: queued.run.sessionId, sessionKey: queued.run.sessionKey, agentId: queued.run.agentId, diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 73983cfdc49..44d006a5ccb 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -220,6 +220,7 @@ export async function handleInlineActions(params: { agentDir, workspaceDir, config: cfg, + allowGatewaySubagentBinding: true, }); const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner); 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/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index b7b6cd31e9f..5bf5f5c2cec 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -91,6 +91,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => enabled: true, })), providers: [], + speechProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], 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/directory.contract.test.ts b/src/channels/plugins/contracts/directory.contract.test.ts new file mode 100644 index 00000000000..97969adc35b --- /dev/null +++ b/src/channels/plugins/contracts/directory.contract.test.ts @@ -0,0 +1,12 @@ +import { describe } from "vitest"; +import { directoryContractRegistry } from "./registry.js"; +import { installChannelDirectoryContractSuite } from "./suites.js"; + +for (const entry of directoryContractRegistry) { + describe(`${entry.id} directory contract`, () => { + installChannelDirectoryContractSuite({ + plugin: entry.plugin, + invokeLookups: entry.invokeLookups, + }); + }); +} diff --git a/src/channels/plugins/contracts/registry.contract.test.ts b/src/channels/plugins/contracts/registry.contract.test.ts index 69ff11d8e68..a379792253a 100644 --- a/src/channels/plugins/contracts/registry.contract.test.ts +++ b/src/channels/plugins/contracts/registry.contract.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { actionContractRegistry, + directoryContractRegistry, pluginContractRegistry, setupContractRegistry, statusContractRegistry, surfaceContractRegistry, + threadingContractRegistry, type ChannelPluginSurface, } from "./registry.js"; @@ -70,4 +72,26 @@ describe("channel contract registry", () => { expect(statusSurfaceIds.has(entry.id)).toBe(true); } }); + + it("only installs deep threading coverage for plugins that declare threading", () => { + const threadingSurfaceIds = new Set( + surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("threading")) + .map((entry) => entry.id), + ); + for (const entry of threadingContractRegistry) { + expect(threadingSurfaceIds.has(entry.id)).toBe(true); + } + }); + + it("only installs deep directory coverage for plugins that declare directory", () => { + const directorySurfaceIds = new Set( + surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("directory")) + .map((entry) => entry.id), + ); + for (const entry of directoryContractRegistry) { + expect(directorySurfaceIds.has(entry.id)).toBe(true); + } + }); }); diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 77bf23b335c..617aa9c2221 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -84,6 +84,17 @@ type SurfaceContractEntry = { surfaces: readonly ChannelPluginSurface[]; }; +type ThreadingContractEntry = { + id: string; + plugin: Pick; +}; + +type DirectoryContractEntry = { + id: string; + plugin: Pick; + invokeLookups: boolean; +}; + const telegramListActionsMock = vi.fn(); const telegramGetCapabilitiesMock = vi.fn(); const discordListActionsMock = vi.fn(); @@ -492,12 +503,12 @@ export const statusContractRegistry: StatusContractEntry[] = [ export const surfaceContractRegistry: SurfaceContractEntry[] = [ { id: "bluebubbles", - plugin: bluebubblesPlugin, + plugin: requireBundledChannelPlugin("bluebubbles"), surfaces: ["actions", "setup", "status", "outbound", "messaging", "threading", "gateway"], }, { id: "discord", - plugin: discordPlugin, + plugin: requireBundledChannelPlugin("discord"), surfaces: [ "actions", "setup", @@ -511,12 +522,12 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "feishu", - plugin: feishuPlugin, + plugin: requireBundledChannelPlugin("feishu"), surfaces: ["actions", "setup", "status", "outbound", "messaging", "directory", "gateway"], }, { id: "googlechat", - plugin: googlechatPlugin, + plugin: requireBundledChannelPlugin("googlechat"), surfaces: [ "actions", "setup", @@ -530,22 +541,22 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "imessage", - plugin: imessagePlugin, + plugin: requireBundledChannelPlugin("imessage"), surfaces: ["setup", "status", "outbound", "messaging", "gateway"], }, { id: "irc", - plugin: ircPlugin, + plugin: requireBundledChannelPlugin("irc"), surfaces: ["setup", "status", "outbound", "messaging", "directory", "gateway"], }, { id: "line", - plugin: linePlugin, + plugin: requireBundledChannelPlugin("line"), surfaces: ["setup", "status", "outbound", "messaging", "directory", "gateway"], }, { id: "matrix", - plugin: matrixPlugin, + plugin: requireBundledChannelPlugin("matrix"), surfaces: [ "actions", "setup", @@ -559,7 +570,7 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "mattermost", - plugin: mattermostPlugin, + plugin: requireBundledChannelPlugin("mattermost"), surfaces: [ "actions", "setup", @@ -573,7 +584,7 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "msteams", - plugin: msteamsPlugin, + plugin: requireBundledChannelPlugin("msteams"), surfaces: [ "actions", "setup", @@ -587,22 +598,22 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "nextcloud-talk", - plugin: nextcloudTalkPlugin, + plugin: requireBundledChannelPlugin("nextcloud-talk"), surfaces: ["setup", "status", "outbound", "messaging", "gateway"], }, { id: "nostr", - plugin: nostrPlugin, + plugin: requireBundledChannelPlugin("nostr"), surfaces: ["setup", "status", "outbound", "messaging", "gateway"], }, { id: "signal", - plugin: signalPlugin, + plugin: requireBundledChannelPlugin("signal"), surfaces: ["actions", "setup", "status", "outbound", "messaging", "gateway"], }, { id: "slack", - plugin: slackPlugin, + plugin: requireBundledChannelPlugin("slack"), surfaces: [ "actions", "setup", @@ -616,12 +627,12 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "synology-chat", - plugin: synologyChatPlugin, + plugin: requireBundledChannelPlugin("synology-chat"), surfaces: ["setup", "outbound", "messaging", "directory", "gateway"], }, { id: "telegram", - plugin: telegramPlugin, + plugin: requireBundledChannelPlugin("telegram"), surfaces: [ "actions", "setup", @@ -635,17 +646,17 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "tlon", - plugin: tlonPlugin, + plugin: requireBundledChannelPlugin("tlon"), surfaces: ["setup", "status", "outbound", "messaging", "gateway"], }, { id: "whatsapp", - plugin: whatsappPlugin, + plugin: requireBundledChannelPlugin("whatsapp"), surfaces: ["actions", "setup", "status", "outbound", "messaging", "directory", "gateway"], }, { id: "zalo", - plugin: zaloPlugin, + plugin: requireBundledChannelPlugin("zalo"), surfaces: [ "actions", "setup", @@ -659,7 +670,7 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "zalouser", - plugin: zalouserPlugin, + plugin: requireBundledChannelPlugin("zalouser"), surfaces: [ "actions", "setup", @@ -672,3 +683,20 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ ], }, ]; + +export const threadingContractRegistry: ThreadingContractEntry[] = surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("threading")) + .map((entry) => ({ + id: entry.id, + plugin: entry.plugin, + })); + +const directoryShapeOnlyIds = new Set(["matrix", "whatsapp", "zalouser"]); + +export const directoryContractRegistry: DirectoryContractEntry[] = surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("directory")) + .map((entry) => ({ + id: entry.id, + plugin: entry.plugin, + invokeLookups: !directoryShapeOnlyIds.has(entry.id), + })); diff --git a/src/channels/plugins/contracts/session-binding.contract.test.ts b/src/channels/plugins/contracts/session-binding.contract.test.ts new file mode 100644 index 00000000000..a21632c4515 --- /dev/null +++ b/src/channels/plugins/contracts/session-binding.contract.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect } from "vitest"; +import { + __testing as feishuThreadBindingTesting, + createFeishuThreadBindingManager, +} from "../../../../extensions/feishu/src/thread-bindings.js"; +import { + __testing as telegramThreadBindingTesting, + createTelegramThreadBindingManager, +} from "../../../../extensions/telegram/src/thread-bindings.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { + __testing as sessionBindingTesting, + getSessionBindingService, +} from "../../../infra/outbound/session-binding-service.js"; +import { installSessionBindingContractSuite } from "./suites.js"; + +const baseCfg = { + session: { mainKey: "main", scope: "per-sender" }, +} satisfies OpenClawConfig; + +beforeEach(() => { + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + feishuThreadBindingTesting.resetFeishuThreadBindingsForTests(); + telegramThreadBindingTesting.resetTelegramThreadBindingsForTests(); +}); + +describe("feishu session binding contract", () => { + installSessionBindingContractSuite({ + expectedCapabilities: { + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current"], + }, + getCapabilities: () => { + createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" }); + return getSessionBindingService().getCapabilities({ + channel: "feishu", + accountId: "default", + }); + }, + bindAndResolve: async () => { + createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" }); + const service = getSessionBindingService(); + const binding = await service.bind({ + targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", + targetKind: "session", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + }, + placement: "current", + metadata: { + agentId: "codex", + label: "codex-main", + }, + }); + expect( + service.resolveByConversation({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + }), + )?.toMatchObject({ + targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", + }); + return binding; + }, + cleanup: async () => { + const manager = createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" }); + manager.stop(); + expect( + getSessionBindingService().resolveByConversation({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + }), + ).toBeNull(); + }, + }); +}); + +describe("telegram session binding contract", () => { + installSessionBindingContractSuite({ + expectedCapabilities: { + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current"], + }, + getCapabilities: () => { + createTelegramThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + }); + return getSessionBindingService().getCapabilities({ + channel: "telegram", + accountId: "default", + }); + }, + bindAndResolve: async () => { + createTelegramThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + }); + const service = getSessionBindingService(); + const binding = await service.bind({ + targetSessionKey: "agent:main:subagent:child-1", + targetKind: "subagent", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-100200300:topic:77", + }, + placement: "current", + metadata: { + boundBy: "user-1", + }, + }); + expect( + service.resolveByConversation({ + channel: "telegram", + accountId: "default", + conversationId: "-100200300:topic:77", + }), + )?.toMatchObject({ + targetSessionKey: "agent:main:subagent:child-1", + }); + return binding; + }, + cleanup: async () => { + const manager = createTelegramThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + }); + manager.stop(); + expect( + getSessionBindingService().resolveByConversation({ + channel: "telegram", + accountId: "default", + conversationId: "-100200300:topic:77", + }), + ).toBeNull(); + }, + }); +}); diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index f2c8a8e3b16..a45abc3ff0b 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -5,13 +5,22 @@ import type { ResolveProviderRuntimeGroupPolicyParams, RuntimeGroupPolicyResolution, } from "../../../config/runtime-group-policy.js"; +import type { + SessionBindingCapabilities, + SessionBindingRecord, +} from "../../../infra/outbound/session-binding-service.js"; +import { createNonExitingRuntime } from "../../../runtime.js"; import { normalizeChatType } from "../../chat-type.js"; import { resolveConversationLabel } from "../../conversation-label.js"; import { validateSenderIdentity } from "../../sender-identity.js"; import type { ChannelAccountSnapshot, ChannelAccountState, + ChannelDirectoryEntry, + ChannelFocusedBindingContext, + ChannelReplyTransport, ChannelSetupInput, + ChannelThreadingToolContext, } from "../types.core.js"; import type { ChannelMessageActionName, @@ -23,6 +32,69 @@ function sortStrings(values: readonly string[]) { return [...values].toSorted((left, right) => left.localeCompare(right)); } +const contractRuntime = createNonExitingRuntime(); +function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) { + expect(["user", "group", "channel"]).toContain(entry.kind); + expect(typeof entry.id).toBe("string"); + expect(entry.id.trim()).not.toBe(""); + if (entry.name !== undefined) { + expect(typeof entry.name).toBe("string"); + } + if (entry.handle !== undefined) { + expect(typeof entry.handle).toBe("string"); + } + if (entry.avatarUrl !== undefined) { + expect(typeof entry.avatarUrl).toBe("string"); + } + if (entry.rank !== undefined) { + expect(typeof entry.rank).toBe("number"); + } +} + +function expectThreadingToolContextShape(context: ChannelThreadingToolContext) { + if (context.currentChannelId !== undefined) { + expect(typeof context.currentChannelId).toBe("string"); + } + if (context.currentChannelProvider !== undefined) { + expect(typeof context.currentChannelProvider).toBe("string"); + } + if (context.currentThreadTs !== undefined) { + expect(typeof context.currentThreadTs).toBe("string"); + } + if (context.currentMessageId !== undefined) { + expect(["string", "number"]).toContain(typeof context.currentMessageId); + } + if (context.replyToMode !== undefined) { + expect(["off", "first", "all"]).toContain(context.replyToMode); + } + if (context.hasRepliedRef !== undefined) { + expect(typeof context.hasRepliedRef).toBe("object"); + } + if (context.skipCrossContextDecoration !== undefined) { + expect(typeof context.skipCrossContextDecoration).toBe("boolean"); + } +} + +function expectReplyTransportShape(transport: ChannelReplyTransport) { + if (transport.replyToId !== undefined && transport.replyToId !== null) { + expect(typeof transport.replyToId).toBe("string"); + } + if (transport.threadId !== undefined && transport.threadId !== null) { + expect(["string", "number"]).toContain(typeof transport.threadId); + } +} + +function expectFocusedBindingShape(binding: ChannelFocusedBindingContext) { + expect(typeof binding.conversationId).toBe("string"); + expect(binding.conversationId.trim()).not.toBe(""); + if (binding.parentConversationId !== undefined) { + expect(typeof binding.parentConversationId).toBe("string"); + } + expect(["current", "child"]).toContain(binding.placement); + expect(typeof binding.labelNoun).toBe("string"); + expect(binding.labelNoun.trim()).not.toBe(""); +} + export function installChannelPluginContractSuite(params: { plugin: Pick; }) { @@ -228,6 +300,189 @@ export function installChannelSurfaceContractSuite(params: { }); } +export function installChannelThreadingContractSuite(params: { + plugin: Pick; +}) { + it("exposes the base threading contract", () => { + expect(params.plugin.threading).toBeDefined(); + }); + + it("keeps threading return values normalized", () => { + const threading = params.plugin.threading; + expect(threading).toBeDefined(); + + if (threading?.resolveReplyToMode) { + expect( + ["off", "first", "all"].includes( + threading.resolveReplyToMode({ + cfg: {} as OpenClawConfig, + accountId: "default", + chatType: "group", + }), + ), + ).toBe(true); + } + + const repliedRef = { value: false }; + const toolContext = threading?.buildToolContext?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + context: { + Channel: "group:test", + From: "user:test", + To: "group:test", + ChatType: "group", + CurrentMessageId: "msg-1", + ReplyToId: "msg-0", + ReplyToIdFull: "thread-0", + MessageThreadId: "thread-0", + NativeChannelId: "native:test", + }, + hasRepliedRef: repliedRef, + }); + + if (toolContext) { + expectThreadingToolContextShape(toolContext); + if (toolContext.hasRepliedRef) { + expect(toolContext.hasRepliedRef).toBe(repliedRef); + } + } + + const autoThreadId = threading?.resolveAutoThreadId?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + to: "group:test", + toolContext, + replyToId: null, + }); + if (autoThreadId !== undefined) { + expect(typeof autoThreadId).toBe("string"); + expect(autoThreadId.trim()).not.toBe(""); + } + + const replyTransport = threading?.resolveReplyTransport?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + threadId: "thread-0", + replyToId: "msg-0", + }); + if (replyTransport) { + expectReplyTransportShape(replyTransport); + } + + const focusedBinding = threading?.resolveFocusedBinding?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + context: { + Channel: "group:test", + From: "user:test", + To: "group:test", + ChatType: "group", + CurrentMessageId: "msg-1", + ReplyToId: "msg-0", + ReplyToIdFull: "thread-0", + MessageThreadId: "thread-0", + NativeChannelId: "native:test", + }, + }); + if (focusedBinding) { + expectFocusedBindingShape(focusedBinding); + } + }); +} + +export function installChannelDirectoryContractSuite(params: { + plugin: Pick; + invokeLookups?: boolean; +}) { + it("exposes the base directory contract", async () => { + const directory = params.plugin.directory; + expect(directory).toBeDefined(); + + if (params.invokeLookups === false) { + return; + } + + const self = await directory?.self?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + runtime: contractRuntime, + }); + if (self) { + expectDirectoryEntryShape(self); + } + + const peers = + (await directory?.listPeers?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + query: "", + limit: 5, + runtime: contractRuntime, + })) ?? []; + expect(Array.isArray(peers)).toBe(true); + for (const peer of peers) { + expectDirectoryEntryShape(peer); + } + + const groups = + (await directory?.listGroups?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + query: "", + limit: 5, + runtime: contractRuntime, + })) ?? []; + expect(Array.isArray(groups)).toBe(true); + for (const group of groups) { + expectDirectoryEntryShape(group); + } + + if (directory?.listGroupMembers && groups[0]?.id) { + const members = await directory.listGroupMembers({ + cfg: {} as OpenClawConfig, + accountId: "default", + groupId: groups[0].id, + limit: 5, + runtime: contractRuntime, + }); + expect(Array.isArray(members)).toBe(true); + for (const member of members) { + expectDirectoryEntryShape(member); + } + } + }); +} + +export function installSessionBindingContractSuite(params: { + getCapabilities: () => SessionBindingCapabilities; + bindAndResolve: () => Promise; + cleanup: () => Promise | void; + expectedCapabilities: SessionBindingCapabilities; +}) { + it("registers the expected session binding capabilities", () => { + expect(params.getCapabilities()).toEqual(params.expectedCapabilities); + }); + + it("binds and resolves a session binding through the shared service", async () => { + const binding = await params.bindAndResolve(); + expect(typeof binding.bindingId).toBe("string"); + expect(binding.bindingId.trim()).not.toBe(""); + expect(typeof binding.targetSessionKey).toBe("string"); + expect(binding.targetSessionKey.trim()).not.toBe(""); + expect(["session", "subagent"]).toContain(binding.targetKind); + expect(typeof binding.conversation.channel).toBe("string"); + expect(typeof binding.conversation.accountId).toBe("string"); + expect(typeof binding.conversation.conversationId).toBe("string"); + expect(["active", "ending", "ended"]).toContain(binding.status); + expect(typeof binding.boundAt).toBe("number"); + }); + + it("cleans up registered bindings", async () => { + await params.cleanup(); + }); +} + type ChannelSetupContractCase = { name: string; cfg: OpenClawConfig; diff --git a/src/channels/plugins/contracts/threading.contract.test.ts b/src/channels/plugins/contracts/threading.contract.test.ts new file mode 100644 index 00000000000..54799b54c44 --- /dev/null +++ b/src/channels/plugins/contracts/threading.contract.test.ts @@ -0,0 +1,11 @@ +import { describe } from "vitest"; +import { threadingContractRegistry } from "./registry.js"; +import { installChannelThreadingContractSuite } from "./suites.js"; + +for (const entry of threadingContractRegistry) { + describe(`${entry.id} threading contract`, () => { + installChannelThreadingContractSuite({ + plugin: entry.plugin, + }); + }); +} 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..5167658040a 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); @@ -396,7 +396,7 @@ export function registerConfigCli(program: Command) { const cmd = program .command("config") .description( - "Non-interactive config helpers (get/set/unset/file/validate). Run without subcommand for the setup wizard.", + "Non-interactive config helpers (get/set/unset/file/validate). Run without subcommand for guided setup.", ) .addHelpText( "after", @@ -405,7 +405,7 @@ export function registerConfigCli(program: Command) { ) .option( "--section
", - "Configure wizard sections (repeatable). Use with no subcommand.", + "Configuration sections for guided setup (repeatable). Use with no subcommand.", (value: string, previous: string[]) => [...previous, value], [] as string[], ) 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.test.ts b/src/cli/plugin-registry.test.ts index f9751d5fed8..336c720dfdb 100644 --- a/src/cli/plugin-registry.test.ts +++ b/src/cli/plugin-registry.test.ts @@ -59,7 +59,7 @@ describe("ensurePluginRegistryLoaded", () => { expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( expect.objectContaining({ - onlyPluginIds: ["telegram"], + onlyPluginIds: [], }), ); }); @@ -85,7 +85,7 @@ describe("ensurePluginRegistryLoaded", () => { expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2); expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ onlyPluginIds: ["telegram"] }), + expect.objectContaining({ onlyPluginIds: [] }), ); expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith( 2, 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/program/command-registry.ts b/src/cli/program/command-registry.ts index 89d59bfb7ee..1955e851357 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -56,7 +56,7 @@ const coreEntries: CoreCliEntry[] = [ commands: [ { name: "onboard", - description: "Interactive setup wizard for gateway, workspace, and skills", + description: "Interactive onboarding for gateway, workspace, and skills", hasSubcommands: false, }, ], @@ -70,7 +70,7 @@ const coreEntries: CoreCliEntry[] = [ { name: "configure", description: - "Interactive setup wizard for credentials, channels, gateway, and agent defaults", + "Interactive configuration for credentials, channels, gateway, and agent defaults", hasSubcommands: false, }, ], @@ -84,7 +84,7 @@ const coreEntries: CoreCliEntry[] = [ { name: "config", description: - "Non-interactive config helpers (get/set/unset/file/validate). Default: starts setup wizard.", + "Non-interactive config helpers (get/set/unset/file/validate). Default: starts guided setup.", hasSubcommands: true, }, ], diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index 48ca6c26e88..e741b6a42ac 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -1,11 +1,6 @@ -import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js"; import { readConfigFileSnapshot } from "../../config/config.js"; -import { formatConfigIssueLines } from "../../config/issue-format.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { colorize, isRich, theme } from "../../terminal/theme.js"; -import { shortenHomePath } from "../../utils.js"; import { shouldMigrateStateFromPath } from "../argv.js"; -import { formatCliCommand } from "../command-format.js"; const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status"]); const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([ @@ -47,7 +42,7 @@ export async function ensureConfigReady(params: { if (!didRunDoctorConfigFlow && shouldMigrateStateFromPath(commandPath)) { didRunDoctorConfigFlow = true; const runDoctorConfigFlow = async () => - loadAndMaybeMigrateDoctorConfig({ + (await import("../../commands/doctor-config-flow.js")).loadAndMaybeMigrateDoctorConfig({ options: { nonInteractive: true }, confirm: async () => false, }); @@ -80,6 +75,7 @@ export async function ensureConfigReady(params: { subcommandName && ALLOWED_INVALID_GATEWAY_SUBCOMMANDS.has(subcommandName)) : false; + const { formatConfigIssueLines } = await import("../../config/issue-format.js"); const issues = snapshot.exists && !snapshot.valid ? formatConfigIssueLines(snapshot.issues, "-", { normalizeRoot: true }) @@ -92,6 +88,12 @@ export async function ensureConfigReady(params: { return; } + const [{ colorize, isRich, theme }, { shortenHomePath }, { formatCliCommand }] = + await Promise.all([ + import("../../terminal/theme.js"), + import("../../utils.js"), + import("../command-format.js"), + ]); const rich = isRich(); const muted = (value: string) => colorize(rich, theme.muted, value); const error = (value: string) => colorize(rich, theme.error, value); diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts index 8756c7bf7d4..ed7a0b10cdb 100644 --- a/src/cli/program/core-command-descriptors.ts +++ b/src/cli/program/core-command-descriptors.ts @@ -12,18 +12,18 @@ export const CORE_CLI_COMMAND_DESCRIPTORS = [ }, { name: "onboard", - description: "Interactive setup wizard for gateway, workspace, and skills", + description: "Interactive onboarding for gateway, workspace, and skills", hasSubcommands: false, }, { name: "configure", - description: "Interactive setup wizard for credentials, channels, gateway, and agent defaults", + description: "Interactive configuration for credentials, channels, gateway, and agent defaults", hasSubcommands: false, }, { name: "config", description: - "Non-interactive config helpers (get/set/unset/file/validate). Default: starts setup wizard.", + "Non-interactive config helpers (get/set/unset/file/validate). Default: starts guided setup.", hasSubcommands: true, }, { diff --git a/src/cli/program/register.configure.ts b/src/cli/program/register.configure.ts index e93fb2386ed..0236503a4f2 100644 --- a/src/cli/program/register.configure.ts +++ b/src/cli/program/register.configure.ts @@ -11,7 +11,7 @@ import { runCommandWithRuntime } from "../cli-utils.js"; export function registerConfigureCommand(program: Command) { program .command("configure") - .description("Interactive setup wizard for credentials, channels, gateway, and agent defaults") + .description("Interactive configuration for credentials, channels, gateway, and agent defaults") .addHelpText( "after", () => diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 0cd2828553b..3909707f263 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -63,7 +63,7 @@ function pickOnboardProviderAuthOptionValues( export function registerOnboardCommand(program: Command) { const command = program .command("onboard") - .description("Interactive wizard to set up the gateway, workspace, and skills") + .description("Interactive onboarding for the gateway, workspace, and skills") .addHelpText( "after", () => @@ -72,7 +72,7 @@ export function registerOnboardCommand(program: Command) { .option("--workspace ", "Agent workspace directory (default: ~/.openclaw/workspace)") .option( "--reset", - "Reset config + credentials + sessions before running wizard (workspace only with --reset-scope full)", + "Reset config + credentials + sessions before running onboard (workspace only with --reset-scope full)", ) .option("--reset-scope ", "Reset scope: config|config+creds+sessions|full") .option("--non-interactive", "Run without prompts", false) @@ -81,8 +81,8 @@ export function registerOnboardCommand(program: Command) { "Acknowledge that agents are powerful and full system access is risky (required for --non-interactive)", false, ) - .option("--flow ", "Wizard flow: quickstart|advanced|manual") - .option("--mode ", "Wizard mode: local|remote") + .option("--flow ", "Onboard flow: quickstart|advanced|manual") + .option("--mode ", "Onboard mode: local|remote") .option("--auth-choice ", `Auth: ${AUTH_CHOICE_HELP}`) .option( "--token-provider ", diff --git a/src/cli/program/register.setup.ts b/src/cli/program/register.setup.ts index 33893d945bb..3546a2adbdf 100644 --- a/src/cli/program/register.setup.ts +++ b/src/cli/program/register.setup.ts @@ -20,9 +20,9 @@ export function registerSetupCommand(program: Command) { "--workspace ", "Agent workspace directory (default: ~/.openclaw/workspace; stored as agents.defaults.workspace)", ) - .option("--wizard", "Run the interactive onboarding wizard", false) - .option("--non-interactive", "Run the wizard without prompts", false) - .option("--mode ", "Wizard mode: local|remote") + .option("--wizard", "Run interactive onboarding", false) + .option("--non-interactive", "Run onboarding without prompts", false) + .option("--mode ", "Onboard mode: local|remote") .option("--remote-url ", "Remote Gateway WebSocket URL") .option("--remote-token ", "Remote Gateway token (optional)") .action(async (opts, command) => { diff --git a/src/cli/route.ts b/src/cli/route.ts index 763000a3d0b..abd347be0a0 100644 --- a/src/cli/route.ts +++ b/src/cli/route.ts @@ -3,8 +3,6 @@ import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; import { getCommandPathWithRootOptions, hasFlag, hasHelpOrVersion } from "./argv.js"; import { emitCliBanner } from "./banner.js"; -import { ensurePluginRegistryLoaded } from "./plugin-registry.js"; -import { ensureConfigReady } from "./program/config-guard.js"; import { findRoutedCommand } from "./program/routes.js"; async function prepareRoutedCommand(params: { @@ -14,6 +12,7 @@ async function prepareRoutedCommand(params: { }) { const suppressDoctorStdout = hasFlag(params.argv, "--json"); emitCliBanner(VERSION, { argv: params.argv }); + const { ensureConfigReady } = await import("./program/config-guard.js"); await ensureConfigReady({ runtime: defaultRuntime, commandPath: params.commandPath, @@ -22,6 +21,7 @@ async function prepareRoutedCommand(params: { const shouldLoadPlugins = typeof params.loadPlugins === "function" ? params.loadPlugins(params.argv) : params.loadPlugins; if (shouldLoadPlugins) { + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); ensurePluginRegistryLoaded({ scope: params.commandPath[0] === "status" || params.commandPath[0] === "health" 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-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index 746eb219fff..da125a4065d 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -238,7 +238,7 @@ export async function applyAuthChoicePluginProvider( const provider = resolveProviderMatch(providers, options.providerId); if (!provider) { await params.prompter.note( - `${options.label} auth plugin is not available. Enable it and re-run the wizard.`, + `${options.label} auth plugin is not available. Enable it and re-run onboarding.`, options.label, ); return { config: nextConfig }; 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/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index 056b2709891..5ad6399fa4a 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -337,6 +337,7 @@ describe("ensureChannelSetupPluginInstalled", () => { hookNames: [], channelIds: [], providerIds: [], + speechProviderIds: [], webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], 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/types.tts.ts b/src/config/types.tts.ts index a6232f9de5a..4703f43ae12 100644 --- a/src/config/types.tts.ts +++ b/src/config/types.tts.ts @@ -1,6 +1,6 @@ import type { SecretInput } from "./types.secrets.js"; -export type TtsProvider = "elevenlabs" | "openai" | "edge"; +export type TtsProvider = string; export type TtsMode = "final" | "all"; @@ -66,9 +66,22 @@ export type TtsConfig = { /** System-level instructions for the TTS model (gpt-4o-mini-tts only). */ instructions?: string; }; - /** Microsoft Edge (node-edge-tts) configuration. */ + /** Legacy alias for Microsoft speech configuration. */ edge?: { - /** Explicitly allow Edge TTS usage (no API key required). */ + /** Explicitly allow Microsoft speech usage (no API key required). */ + enabled?: boolean; + voice?: string; + lang?: string; + outputFormat?: string; + pitch?: string; + rate?: string; + volume?: string; + saveSubtitles?: boolean; + proxy?: string; + timeoutMs?: number; + }; + /** Preferred alias for Microsoft speech configuration. */ + microsoft?: { enabled?: boolean; voice?: string; lang?: string; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 305efab4b26..199637bba52 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -353,9 +353,24 @@ export const MarkdownConfigSchema = z .strict() .optional(); -export const TtsProviderSchema = z.enum(["elevenlabs", "openai", "edge"]); +export const TtsProviderSchema = z.string().min(1); export const TtsModeSchema = z.enum(["final", "all"]); export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]); +const TtsMicrosoftConfigSchema = z + .object({ + enabled: z.boolean().optional(), + voice: z.string().optional(), + lang: z.string().optional(), + outputFormat: z.string().optional(), + pitch: z.string().optional(), + rate: z.string().optional(), + volume: z.string().optional(), + saveSubtitles: z.boolean().optional(), + proxy: z.string().optional(), + timeoutMs: z.number().int().min(1000).max(120000).optional(), + }) + .strict() + .optional(); export const TtsConfigSchema = z .object({ auto: TtsAutoSchema.optional(), @@ -409,21 +424,8 @@ export const TtsConfigSchema = z }) .strict() .optional(), - edge: z - .object({ - enabled: z.boolean().optional(), - voice: z.string().optional(), - lang: z.string().optional(), - outputFormat: z.string().optional(), - pitch: z.string().optional(), - rate: z.string().optional(), - volume: z.string().optional(), - saveSubtitles: z.boolean().optional(), - proxy: z.string().optional(), - timeoutMs: z.number().int().min(1000).max(120000).optional(), - }) - .strict() - .optional(), + edge: TtsMicrosoftConfigSchema, + microsoft: TtsMicrosoftConfigSchema, prefsPath: z.string().optional(), maxTextLength: z.number().int().min(1).optional(), timeoutMs: z.number().int().min(1000).max(120000).optional(), 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.fast-mode.test.ts b/src/cron/isolated-agent/run.fast-mode.test.ts index abe50ea5554..1a176709a04 100644 --- a/src/cron/isolated-agent/run.fast-mode.test.ts +++ b/src/cron/isolated-agent/run.fast-mode.test.ts @@ -81,6 +81,7 @@ async function runFastModeCase(params: { provider: "openai", model: "gpt-4", fastMode: params.expectedFastMode, + allowGatewaySubagentBinding: true, }); } 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..78f045d03cf 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, @@ -601,6 +622,9 @@ export async function runCronIsolatedAgentTurn(params: { sessionKey: agentSessionKey, agentId, trigger: "cron", + // Cron runs execute inside the gateway process and need the same + // explicit subagent late-binding as other gateway-owned runners. + allowGatewaySubagentBinding: true, // Cron jobs are trusted local automation, so isolated runs should // inherit owner-only tooling like local `openclaw agent` runs. senderIsOwner: true, diff --git a/src/cron/isolated-agent/subagent-followup.ts b/src/cron/isolated-agent/subagent-followup.ts index a337fe528b7..b83bc7e1040 100644 --- a/src/cron/isolated-agent/subagent-followup.ts +++ b/src/cron/isolated-agent/subagent-followup.ts @@ -3,11 +3,14 @@ import { readLatestAssistantReply } from "../../agents/tools/agent-step.js"; import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import { callGateway } from "../../gateway/call.js"; -const FAST_TEST_MODE = process.env.OPENCLAW_TEST_FAST === "1"; - -const CRON_SUBAGENT_WAIT_MIN_MS = FAST_TEST_MODE ? 10 : 30_000; -const CRON_SUBAGENT_FINAL_REPLY_GRACE_MS = FAST_TEST_MODE ? 50 : 5_000; -const CRON_SUBAGENT_GRACE_POLL_MS = FAST_TEST_MODE ? 8 : 200; +function resolveCronSubagentTimings() { + const fastTestMode = process.env.OPENCLAW_TEST_FAST === "1"; + return { + waitMinMs: fastTestMode ? 10 : 30_000, + finalReplyGraceMs: fastTestMode ? 50 : 5_000, + gracePollMs: fastTestMode ? 8 : 200, + }; +} const SUBAGENT_FOLLOWUP_HINTS = [ "subagent spawned", @@ -121,8 +124,9 @@ export async function waitForDescendantSubagentSummary(params: { timeoutMs: number; observedActiveDescendants?: boolean; }): Promise { + const timings = resolveCronSubagentTimings(); const initialReply = params.initialReply?.trim(); - const deadline = Date.now() + Math.max(CRON_SUBAGENT_WAIT_MIN_MS, Math.floor(params.timeoutMs)); + const deadline = Date.now() + Math.max(timings.waitMinMs, Math.floor(params.timeoutMs)); // Snapshot the currently active descendant run IDs. const getActiveRuns = () => @@ -166,8 +170,8 @@ export async function waitForDescendantSubagentSummary(params: { // --- Grace period: wait for the cron agent's synthesis --- // After the subagent announces fire and the cron agent processes them, it // produces a new assistant message. Poll briefly (bounded by - // CRON_SUBAGENT_FINAL_REPLY_GRACE_MS) to capture that synthesis. - const gracePeriodDeadline = Math.min(Date.now() + CRON_SUBAGENT_FINAL_REPLY_GRACE_MS, deadline); + // finalReplyGraceMs) to capture that synthesis. + const gracePeriodDeadline = Math.min(Date.now() + timings.finalReplyGraceMs, deadline); const resolveUsableLatestReply = async () => { const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim(); @@ -186,7 +190,7 @@ export async function waitForDescendantSubagentSummary(params: { if (latest) { return latest; } - await new Promise((resolve) => setTimeout(resolve, CRON_SUBAGENT_GRACE_POLL_MS)); + await new Promise((resolve) => setTimeout(resolve, timings.gracePollMs)); } // Final read after grace period expires. 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/entry.ts b/src/entry.ts index 3496e48f0e9..bee75ea2fcb 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -43,7 +43,7 @@ if ( } else { const { installGaxiosFetchCompat } = await import("./infra/gaxios-fetch-compat.js"); - installGaxiosFetchCompat(); + await installGaxiosFetchCompat(); process.title = "openclaw"; ensureOpenClawExecMarkerOnProcess(); installProcessWarningFilter(); diff --git a/src/extensionAPI.ts b/src/extensionAPI.ts deleted file mode 100644 index 8b886dfef5a..00000000000 --- a/src/extensionAPI.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { resolveAgentDir, resolveAgentWorkspaceDir } from "./agents/agent-scope.ts"; - -export { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./agents/defaults.ts"; -export { resolveAgentIdentity } from "./agents/identity.ts"; -export { resolveThinkingDefault } from "./agents/model-selection.ts"; -export { runEmbeddedPiAgent } from "./agents/pi-embedded.ts"; -export { resolveAgentTimeoutMs } from "./agents/timeout.ts"; -export { ensureAgentWorkspace } from "./agents/workspace.ts"; -export { - resolveStorePath, - loadSessionStore, - saveSessionStore, - resolveSessionFilePath, -} from "./config/sessions.ts"; 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-methods/config.ts b/src/gateway/server-methods/config.ts index 6e6cf9e92e3..977a59f00b5 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -249,6 +249,9 @@ function loadSchemaWithPlugins(): ConfigSchemaResponse { config: cfg, cache: true, workspaceDir, + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, logger: { info: () => {}, warn: () => {}, diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index 70fcdcdf85e..b806bbdd14d 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolvePluginTools } from "../../plugins/tools.js"; import { ErrorCodes } from "../protocol/index.js"; import { toolsCatalogHandlers } from "./tools-catalog.js"; @@ -117,4 +118,16 @@ describe("tools.catalog handler", () => { optional: true, }); }); + + it("opts plugin tool catalog loads into gateway subagent binding", async () => { + const { invoke } = createInvokeParams({}); + + await invoke(); + + expect(vi.mocked(resolvePluginTools)).toHaveBeenCalledWith( + expect.objectContaining({ + allowGatewaySubagentBinding: true, + }), + ); + }); }); diff --git a/src/gateway/server-methods/tools-catalog.ts b/src/gateway/server-methods/tools-catalog.ts index 27f488822a3..2eec921c4c0 100644 --- a/src/gateway/server-methods/tools-catalog.ts +++ b/src/gateway/server-methods/tools-catalog.ts @@ -85,6 +85,7 @@ function buildPluginGroups(params: { existingToolNames: params.existingToolNames, toolAllowlist: ["group:plugins"], suppressNameConflicts: true, + allowGatewaySubagentBinding: true, }); const groups = new Map(); for (const tool of pluginTools) { diff --git a/src/gateway/server-methods/tts.ts b/src/gateway/server-methods/tts.ts index 5e4e8254eba..0f7729bf3b5 100644 --- a/src/gateway/server-methods/tts.ts +++ b/src/gateway/server-methods/tts.ts @@ -1,4 +1,5 @@ import { loadConfig } from "../../config/config.js"; +import { listSpeechProviders, normalizeSpeechProviderId } from "../../tts/provider-registry.js"; import { OPENAI_TTS_MODELS, OPENAI_TTS_VOICES, @@ -26,9 +27,9 @@ export const ttsHandlers: GatewayRequestHandlers = { const prefsPath = resolveTtsPrefsPath(config); const provider = getTtsProvider(config, prefsPath); const autoMode = resolveTtsAutoMode({ config, prefsPath }); - const fallbackProviders = resolveTtsProviderOrder(provider) + const fallbackProviders = resolveTtsProviderOrder(provider, cfg) .slice(1) - .filter((candidate) => isTtsProviderConfigured(config, candidate)); + .filter((candidate) => isTtsProviderConfigured(config, candidate, cfg)); respond(true, { enabled: isTtsEnabled(config, prefsPath), auto: autoMode, @@ -38,7 +39,7 @@ export const ttsHandlers: GatewayRequestHandlers = { prefsPath, hasOpenAIKey: Boolean(resolveTtsApiKey(config, "openai")), hasElevenLabsKey: Boolean(resolveTtsApiKey(config, "elevenlabs")), - edgeEnabled: isTtsProviderConfigured(config, "edge"), + microsoftEnabled: isTtsProviderConfigured(config, "microsoft", cfg), }); } catch (err) { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); @@ -99,20 +100,23 @@ export const ttsHandlers: GatewayRequestHandlers = { } }, "tts.setProvider": async ({ params, respond }) => { - const provider = typeof params.provider === "string" ? params.provider.trim() : ""; - if (provider !== "openai" && provider !== "elevenlabs" && provider !== "edge") { + const provider = normalizeSpeechProviderId( + typeof params.provider === "string" ? params.provider.trim() : "", + ); + const cfg = loadConfig(); + const knownProviders = new Set(listSpeechProviders(cfg).map((entry) => entry.id)); + if (!provider || !knownProviders.has(provider)) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, - "Invalid provider. Use openai, elevenlabs, or edge.", + "Invalid provider. Use a registered TTS provider id such as openai, elevenlabs, or microsoft.", ), ); return; } try { - const cfg = loadConfig(); const config = resolveTtsConfig(cfg); const prefsPath = resolveTtsPrefsPath(config); setTtsProvider(prefsPath, provider); @@ -127,27 +131,19 @@ export const ttsHandlers: GatewayRequestHandlers = { const config = resolveTtsConfig(cfg); const prefsPath = resolveTtsPrefsPath(config); respond(true, { - providers: [ - { - id: "openai", - name: "OpenAI", - configured: Boolean(resolveTtsApiKey(config, "openai")), - models: [...OPENAI_TTS_MODELS], - voices: [...OPENAI_TTS_VOICES], - }, - { - id: "elevenlabs", - name: "ElevenLabs", - configured: Boolean(resolveTtsApiKey(config, "elevenlabs")), - models: ["eleven_multilingual_v2", "eleven_turbo_v2_5", "eleven_monolingual_v1"], - }, - { - id: "edge", - name: "Edge TTS", - configured: isTtsProviderConfigured(config, "edge"), - models: [], - }, - ], + providers: listSpeechProviders(cfg).map((provider) => ({ + id: provider.id, + name: provider.label, + configured: provider.isConfigured({ cfg, config }), + models: + provider.id === "openai" && provider.models == null + ? [...OPENAI_TTS_MODELS] + : [...(provider.models ?? [])], + voices: + provider.id === "openai" && provider.voices == null + ? [...OPENAI_TTS_VOICES] + : [...(provider.voices ?? [])], + })), active: getTtsProvider(config, prefsPath), }); } catch (err) { 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..58f5c9da4eb 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -29,6 +29,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ channelSetups: [], commands: [], providers: [], + speechProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], @@ -52,7 +53,9 @@ async function importServerPluginsModule(): Promise { return import("./server-plugins.js"); } -function createSubagentRuntime(serverPlugins: ServerPluginsModule): PluginRuntime["subagent"] { +async function createSubagentRuntime( + serverPlugins: ServerPluginsModule, +): Promise { const log = { info: vi.fn(), warn: vi.fn(), @@ -68,17 +71,20 @@ function createSubagentRuntime(serverPlugins: ServerPluginsModule): PluginRuntim baseMethods: [], }); const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as - | { runtimeOptions?: { subagent?: PluginRuntime["subagent"] } } + | { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } } | undefined; - if (!call?.runtimeOptions?.subagent) { - throw new Error("Expected loadGatewayPlugins to provide subagent runtime"); + if (call?.runtimeOptions?.allowGatewaySubagentBinding !== true) { + throw new Error("Expected loadGatewayPlugins to opt into gateway subagent binding"); } - return call.runtimeOptions.subagent; + const runtimeModule = await import("../plugins/runtime/index.js"); + return runtimeModule.createPluginRuntime({ allowGatewaySubagentBinding: true }).subagent; } -beforeEach(() => { +beforeEach(async () => { loadOpenClawPlugins.mockReset(); handleGatewayRequest.mockReset(); + const runtimeModule = await import("../plugins/runtime/index.js"); + runtimeModule.clearGatewaySubagentRuntime(); handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => { switch (opts.req.method) { case "agent": @@ -99,7 +105,9 @@ beforeEach(() => { }); }); -afterEach(() => { +afterEach(async () => { + const runtimeModule = await import("../plugins/runtime/index.js"); + runtimeModule.clearGatewaySubagentRuntime(); vi.resetModules(); }); @@ -156,15 +164,80 @@ describe("loadGatewayPlugins", () => { baseMethods: [], }); - const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0]; - const subagent = call?.runtimeOptions?.subagent; + const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as + | { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } } + | undefined; + expect(call?.runtimeOptions?.allowGatewaySubagentBinding).toBe(true); + const runtimeModule = await import("../plugins/runtime/index.js"); + const subagent = runtimeModule.createPluginRuntime({ + allowGatewaySubagentBinding: true, + }).subagent; expect(typeof subagent?.getSessionMessages).toBe("function"); 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); + const runtime = await createSubagentRuntime(first); const staleContext = createTestContext("stale"); first.setFallbackGatewayContext(staleContext); @@ -182,7 +255,7 @@ describe("loadGatewayPlugins", () => { test("uses updated fallback context after context replacement", async () => { const serverPlugins = await importServerPluginsModule(); - const runtime = createSubagentRuntime(serverPlugins); + const runtime = await createSubagentRuntime(serverPlugins); const firstContext = createTestContext("before-restart"); const secondContext = createTestContext("after-restart"); @@ -197,7 +270,7 @@ describe("loadGatewayPlugins", () => { test("reflects fallback context object mutation at dispatch time", async () => { const serverPlugins = await importServerPluginsModule(); - const runtime = createSubagentRuntime(serverPlugins); + const runtime = await createSubagentRuntime(serverPlugins); const context = { marker: "before-mutation" } as GatewayRequestContext & { marker: string; }; diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 7d8b2a8a051..587aa71dc41 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { loadConfig } from "../config/config.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; +import { setGatewaySubagentRuntime } from "../plugins/runtime/index.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./protocol/client-info.js"; import type { ErrorShape } from "./protocol/index.js"; @@ -172,7 +173,16 @@ export function loadGatewayPlugins(params: { }; coreGatewayHandlers: Record; baseMethods: string[]; + preferSetupRuntimeForChannelPlugins?: boolean; + logDiagnostics?: boolean; }) { + // Set the process-global gateway subagent runtime BEFORE loading plugins. + // Gateway-owned registries may already exist from schema loads, so the + // gateway path opts those runtimes into late binding rather than changing + // the default subagent behavior for every plugin runtime in the process. + const gatewaySubagent = createGatewaySubagentRuntime(); + setGatewaySubagentRuntime(gatewaySubagent); + const pluginRegistry = loadOpenClawPlugins({ config: params.cfg, workspaceDir: params.workspaceDir, @@ -184,12 +194,13 @@ export function loadGatewayPlugins(params: { }, coreGatewayHandlers: params.coreGatewayHandlers, runtimeOptions: { - subagent: createGatewaySubagentRuntime(), + allowGatewaySubagentBinding: true, }, + 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.agent.gateway-server-agent.mocks.ts b/src/gateway/server.agent.gateway-server-agent.mocks.ts index acf507dbde2..f6b29fe041a 100644 --- a/src/gateway/server.agent.gateway-server-agent.mocks.ts +++ b/src/gateway/server.agent.gateway-server-agent.mocks.ts @@ -1,25 +1,9 @@ import { vi } from "vitest"; -import type { PluginRegistry } from "../plugins/registry.js"; +import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; export const registryState: { registry: PluginRegistry } = { - registry: { - plugins: [], - tools: [], - hooks: [], - typedHooks: [], - channels: [], - channelSetups: [], - providers: [], - webSearchProviders: [], - gatewayHandlers: {}, - httpHandlers: [], - httpRoutes: [], - cliRegistrars: [], - services: [], - commands: [], - diagnostics: [], - } as PluginRegistry, + registry: createEmptyPluginRegistry(), }; export function setRegistry(registry: PluginRegistry) { 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..e05fcc85320 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -146,6 +146,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ ], channelSetups: [], providers: [], + speechProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], @@ -367,6 +368,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 +514,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 +527,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/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index f47e80a9bf6..96ede78ef00 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -380,6 +380,14 @@ describe("POST /tools/invoke", () => { ); }); + it("opts direct gateway tool invocation into gateway subagent binding", async () => { + allowAgentsListForMain(); + const res = await invokeAgentsListAuthed({ sessionKey: "main" }); + + expect(res.status).toBe(200); + expect(lastCreateOpenClawToolsContext?.allowGatewaySubagentBinding).toBe(true); + }); + it("blocks tool execution when before_tool_call rejects the invoke", async () => { setMainAllowedTools({ allow: ["tools_invoke_test"] }); hookMocks.runBeforeToolCallHook.mockResolvedValueOnce({ diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 0cccafce999..80b6dc37733 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -254,6 +254,7 @@ export async function handleToolsInvokeHttpRequest( agentAccountId: accountId, agentTo, agentThreadId, + allowGatewaySubagentBinding: true, // HTTP callers consume tool output directly; preserve raw media invoke payloads. allowMediaInvokeCommands: true, config: cfg, 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/index.ts b/src/index.ts index 92cf6269cc4..80069007220 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,7 +37,7 @@ export async function runLegacyCliEntry(argv: string[] = process.argv): Promise< import("./cli/run-main.js"), ]); - installGaxiosFetchCompat(); + await installGaxiosFetchCompat(); await runCli(argv); } 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/infra/gaxios-fetch-compat.test.ts b/src/infra/gaxios-fetch-compat.test.ts index 4d7559f3eee..b3cbf68a1ab 100644 --- a/src/infra/gaxios-fetch-compat.test.ts +++ b/src/infra/gaxios-fetch-compat.test.ts @@ -2,14 +2,25 @@ import { HttpsProxyAgent } from "https-proxy-agent"; import { ProxyAgent } from "undici"; import { afterEach, describe, expect, it, vi } from "vitest"; +const TEST_GAXIOS_CONSTRUCTOR_OVERRIDE = "__OPENCLAW_TEST_GAXIOS_CONSTRUCTOR__"; + describe("gaxios fetch compat", () => { afterEach(() => { + Reflect.deleteProperty(globalThis as object, TEST_GAXIOS_CONSTRUCTOR_OVERRIDE); vi.resetModules(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it("uses native fetch without defining window or importing node-fetch", async () => { + type MockRequestConfig = RequestInit & { + fetchImplementation?: typeof fetch; + responseType?: string; + url: string; + }; + let MockGaxiosCtor!: new () => { + request(config: MockRequestConfig): Promise<{ data: string } & object>; + }; const fetchMock = vi.fn(async () => { return new Response("ok", { headers: { "content-type": "text/plain" }, @@ -18,16 +29,30 @@ describe("gaxios fetch compat", () => { }); vi.stubGlobal("fetch", fetchMock); - vi.doMock("node-fetch", () => { - throw new Error("node-fetch should not load"); - }); + class MockGaxios { + _defaultAdapter!: (config: MockRequestConfig) => Promise; + + async request(config: MockRequestConfig) { + const response = await this._defaultAdapter(config); + return { + ...(response as object), + data: await response.text(), + }; + } + } + MockGaxiosCtor = MockGaxios; + + MockGaxios.prototype._defaultAdapter = async (config: MockRequestConfig) => { + const fetchImplementation = config.fetchImplementation ?? fetch; + return await fetchImplementation(config.url, config); + }; + (globalThis as Record)[TEST_GAXIOS_CONSTRUCTOR_OVERRIDE] = MockGaxios; const { installGaxiosFetchCompat } = await import("./gaxios-fetch-compat.js"); - const { Gaxios } = await import("gaxios"); - installGaxiosFetchCompat(); + await installGaxiosFetchCompat(); - const res = await new Gaxios().request({ + const res = await new MockGaxiosCtor().request({ responseType: "text", url: "https://example.com", }); @@ -37,6 +62,25 @@ describe("gaxios fetch compat", () => { expect("window" in globalThis).toBe(false); }); + it("falls back to a legacy window fetch shim when gaxios is unavailable", async () => { + const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + vi.stubGlobal("fetch", vi.fn()); + Reflect.deleteProperty(globalThis as object, "window"); + (globalThis as Record)[TEST_GAXIOS_CONSTRUCTOR_OVERRIDE] = null; + const { installGaxiosFetchCompat } = await import("./gaxios-fetch-compat.js"); + + try { + await expect(installGaxiosFetchCompat()).resolves.toBeUndefined(); + expect((globalThis as { window?: { fetch?: typeof fetch } }).window?.fetch).toBe(fetch); + await expect(installGaxiosFetchCompat()).resolves.toBeUndefined(); + } finally { + Reflect.deleteProperty(globalThis as object, "window"); + if (originalWindowDescriptor) { + Object.defineProperty(globalThis, "window", originalWindowDescriptor); + } + } + }); + it("translates proxy agents into undici dispatchers for native fetch", async () => { const fetchMock = vi.fn(async () => { return new Response("ok", { diff --git a/src/infra/gaxios-fetch-compat.ts b/src/infra/gaxios-fetch-compat.ts index e4d3688d7e5..6f9d34bf7af 100644 --- a/src/infra/gaxios-fetch-compat.ts +++ b/src/infra/gaxios-fetch-compat.ts @@ -1,5 +1,6 @@ +import { createRequire } from "node:module"; import type { ConnectionOptions } from "node:tls"; -import { Gaxios } from "gaxios"; +import { pathToFileURL } from "node:url"; import type { Dispatcher } from "undici"; import { Agent as UndiciAgent, ProxyAgent } from "undici"; @@ -27,10 +28,16 @@ type TlsAgentLike = { }; type GaxiosPrototype = { - _defaultAdapter: (this: Gaxios, config: GaxiosFetchRequestInit) => Promise; + _defaultAdapter: (this: unknown, config: GaxiosFetchRequestInit) => Promise; }; -let installState: "not-installed" | "installed" = "not-installed"; +type GaxiosConstructor = { + prototype: GaxiosPrototype; +}; + +const TEST_GAXIOS_CONSTRUCTOR_OVERRIDE = "__OPENCLAW_TEST_GAXIOS_CONSTRUCTOR__"; + +let installState: "not-installed" | "installing" | "shimmed" | "installed" = "not-installed"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -161,6 +168,78 @@ function buildDispatcher(init: GaxiosFetchRequestInit, url: URL): Dispatcher | u return undefined; } +function isModuleNotFoundError(err: unknown): err is NodeJS.ErrnoException { + return isRecord(err) && (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "MODULE_NOT_FOUND"); +} + +function hasGaxiosConstructorShape(value: unknown): value is GaxiosConstructor { + return ( + typeof value === "function" && + "prototype" in value && + isRecord(value.prototype) && + typeof value.prototype._defaultAdapter === "function" + ); +} + +function getTestGaxiosConstructorOverride(): GaxiosConstructor | null | undefined { + const testGlobal = globalThis as Record; + if (!Object.prototype.hasOwnProperty.call(testGlobal, TEST_GAXIOS_CONSTRUCTOR_OVERRIDE)) { + return undefined; + } + const override = testGlobal[TEST_GAXIOS_CONSTRUCTOR_OVERRIDE]; + if (override === null) { + return null; + } + if (hasGaxiosConstructorShape(override)) { + return override; + } + throw new Error("invalid gaxios test constructor override"); +} + +function isDirectGaxiosImportMiss(err: unknown): boolean { + if (!isModuleNotFoundError(err)) { + return false; + } + return ( + typeof err.message === "string" && + (err.message.includes("Cannot find package 'gaxios'") || + err.message.includes("Cannot find module 'gaxios'")) + ); +} + +async function loadGaxiosConstructor(): Promise { + const testOverride = getTestGaxiosConstructorOverride(); + if (testOverride !== undefined) { + return testOverride; + } + + try { + const require = createRequire(import.meta.url); + const resolvedPath = require.resolve("gaxios"); + const mod = await import(pathToFileURL(resolvedPath).href); + const candidate = isRecord(mod) ? mod.Gaxios : undefined; + if (!hasGaxiosConstructorShape(candidate)) { + throw new Error("gaxios: missing Gaxios export"); + } + return candidate; + } catch (err) { + if (isDirectGaxiosImportMiss(err)) { + return null; + } + throw err; + } +} + +function installLegacyWindowFetchShim(): void { + if ( + typeof globalThis.fetch !== "function" || + typeof (globalThis as Record).window !== "undefined" + ) { + return; + } + (globalThis as Record).window = { fetch: globalThis.fetch }; +} + export function createGaxiosCompatFetch(baseFetch: typeof fetch = globalThis.fetch): typeof fetch { return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const gaxiosInit = (init ?? {}) as GaxiosFetchRequestInit; @@ -186,27 +265,41 @@ export function createGaxiosCompatFetch(baseFetch: typeof fetch = globalThis.fet }; } -export function installGaxiosFetchCompat(): void { - if (installState === "installed" || typeof globalThis.fetch !== "function") { +export async function installGaxiosFetchCompat(): Promise { + if (installState !== "not-installed" || typeof globalThis.fetch !== "function") { return; } - const prototype = Gaxios.prototype as unknown as GaxiosPrototype; - const originalDefaultAdapter = prototype._defaultAdapter; - const compatFetch = createGaxiosCompatFetch(); + installState = "installing"; - prototype._defaultAdapter = function patchedDefaultAdapter( - this: Gaxios, - config: GaxiosFetchRequestInit, - ): Promise { - if (config.fetchImplementation) { - return originalDefaultAdapter.call(this, config); + try { + const Gaxios = await loadGaxiosConstructor(); + if (!Gaxios) { + installLegacyWindowFetchShim(); + installState = "shimmed"; + return; } - return originalDefaultAdapter.call(this, { - ...config, - fetchImplementation: compatFetch, - }); - }; - installState = "installed"; + const prototype = Gaxios.prototype; + const originalDefaultAdapter = prototype._defaultAdapter; + const compatFetch = createGaxiosCompatFetch(); + + prototype._defaultAdapter = function patchedDefaultAdapter( + this: unknown, + config: GaxiosFetchRequestInit, + ): Promise { + if (config.fetchImplementation) { + return originalDefaultAdapter.call(this, config); + } + return originalDefaultAdapter.call(this, { + ...config, + fetchImplementation: compatFetch, + }); + }; + + installState = "installed"; + } catch (err) { + installState = "not-installed"; + throw err; + } } diff --git a/src/infra/outbound/channel-resolution.test.ts b/src/infra/outbound/channel-resolution.test.ts index 3d8f8c4fbdd..30480fd0046 100644 --- a/src/infra/outbound/channel-resolution.test.ts +++ b/src/infra/outbound/channel-resolution.test.ts @@ -123,6 +123,9 @@ describe("outbound channel resolution", () => { expect(loadOpenClawPluginsMock).toHaveBeenCalledWith({ config: { autoEnabled: true }, workspaceDir: "/tmp/workspace", + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, }); getChannelPluginMock.mockReturnValue(undefined); @@ -131,6 +134,13 @@ describe("outbound channel resolution", () => { cfg: { channels: {} } as never, }); expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); + expect(loadOpenClawPluginsMock).toHaveBeenLastCalledWith({ + config: { autoEnabled: true }, + workspaceDir: "/tmp/workspace", + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }); }); it("bootstraps when the active registry has other channels but not the requested one", async () => { diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index c39ff8bb210..15372daa2a1 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -54,6 +54,9 @@ function maybeBootstrapChannelPlugin(params: { loadOpenClawPlugins({ config: autoEnabled, workspaceDir, + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, }); } catch { // Allow a follow-up resolution attempt if bootstrap failed transiently. diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index baf96781c27..261ff0203bc 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -50,6 +50,13 @@ describe("resolveProviderAuths key normalization", () => { process.env.HOME = base; process.env.USERPROFILE = base; + if (process.platform === "win32") { + const match = base.match(/^([A-Za-z]:)(.*)$/); + if (match) { + process.env.HOMEDRIVE = match[1]; + process.env.HOMEPATH = match[2] || "\\"; + } + } delete process.env.OPENCLAW_HOME; process.env.OPENCLAW_STATE_DIR = path.join(base, ".openclaw"); for (const [key, value] of Object.entries(env)) { diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 00bba63f2e1..dc62cece821 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { dedupeProfileIds, ensureAuthProfileStore, @@ -9,6 +12,7 @@ import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { resolveUsableCustomProviderApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import type { UsageProviderId } from "./provider-usage.types.js"; @@ -28,6 +32,39 @@ type UsageAuthState = { agentDir?: string; }; +function parseGoogleUsageToken(apiKey: string): string { + try { + const parsed = JSON.parse(apiKey) as { token?: unknown }; + if (typeof parsed?.token === "string") { + return parsed.token; + } + } catch { + // ignore + } + return apiKey; +} + +function resolveLegacyZaiUsageToken(env: NodeJS.ProcessEnv): string | undefined { + try { + const authPath = path.join( + resolveRequiredHomeDir(env, os.homedir), + ".pi", + "agent", + "auth.json", + ); + if (!fs.existsSync(authPath)) { + return undefined; + } + const parsed = JSON.parse(fs.readFileSync(authPath, "utf8")) as Record< + string, + { access?: string } + >; + return parsed["z-ai"]?.access || parsed.zai?.access; + } catch { + return undefined; + } +} + function resolveProviderApiKeyFromConfigAndStore(params: { state: UsageAuthState; providerIds: string[]; @@ -166,6 +203,52 @@ async function resolveProviderUsageAuthViaPlugin(params: { }; } +async function resolveProviderUsageAuthFallback(params: { + state: UsageAuthState; + provider: UsageProviderId; +}): Promise { + switch (params.provider) { + case "anthropic": + case "github-copilot": + case "openai-codex": + return await resolveOAuthToken(params); + case "google-gemini-cli": { + const auth = await resolveOAuthToken(params); + return auth ? { ...auth, token: parseGoogleUsageToken(auth.token) } : null; + } + case "zai": { + const apiKey = resolveProviderApiKeyFromConfigAndStore({ + state: params.state, + providerIds: ["zai", "z-ai"], + envDirect: [params.state.env.ZAI_API_KEY, params.state.env.Z_AI_API_KEY], + }); + if (apiKey) { + return { provider: "zai", token: apiKey }; + } + const legacyToken = resolveLegacyZaiUsageToken(params.state.env); + return legacyToken ? { provider: "zai", token: legacyToken } : null; + } + case "minimax": { + const apiKey = resolveProviderApiKeyFromConfigAndStore({ + state: params.state, + providerIds: ["minimax"], + envDirect: [params.state.env.MINIMAX_CODE_PLAN_KEY, params.state.env.MINIMAX_API_KEY], + }); + return apiKey ? { provider: "minimax", token: apiKey } : null; + } + case "xiaomi": { + const apiKey = resolveProviderApiKeyFromConfigAndStore({ + state: params.state, + providerIds: ["xiaomi"], + envDirect: [params.state.env.XIAOMI_API_KEY], + }); + return apiKey ? { provider: "xiaomi", token: apiKey } : null; + } + default: + return null; + } +} + export async function resolveProviderAuths(params: { providers: UsageProviderId[]; auth?: ProviderAuth[]; @@ -192,6 +275,14 @@ export async function resolveProviderAuths(params: { }); if (pluginAuth) { auths.push(pluginAuth); + continue; + } + const fallbackAuth = await resolveProviderUsageAuthFallback({ + state, + provider, + }); + if (fallbackAuth) { + auths.push(fallbackAuth); } } diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index d34c55c22d3..a8658889c68 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -2,6 +2,13 @@ import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { resolveProviderUsageSnapshotWithPlugin } from "../plugins/provider-runtime.js"; import { resolveFetch } from "./fetch.js"; import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js"; +import { + fetchClaudeUsage, + fetchCodexUsage, + fetchGeminiUsage, + fetchMinimaxUsage, + fetchZaiUsage, +} from "./provider-usage.fetch.js"; import { DEFAULT_TIMEOUT_MS, ignoredErrors, @@ -15,6 +22,99 @@ import type { UsageSummary, } from "./provider-usage.types.js"; +async function fetchCopilotUsageFallback( + token: string, + timeoutMs: number, + fetchFn: typeof fetch, +): Promise { + const res = await fetchFn("https://api.github.com/copilot_internal/user", { + headers: { + Authorization: `token ${token}`, + "Editor-Version": "vscode/1.96.2", + "User-Agent": "GitHubCopilotChat/0.26.7", + "X-Github-Api-Version": "2025-04-01", + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) { + return { + provider: "github-copilot", + displayName: PROVIDER_LABELS["github-copilot"], + windows: [], + error: `HTTP ${res.status}`, + }; + } + const data = (await res.json()) as { + quota_snapshots?: { + premium_interactions?: { percent_remaining?: number | null }; + chat?: { percent_remaining?: number | null }; + }; + copilot_plan?: string; + }; + const windows = []; + const premiumRemaining = data.quota_snapshots?.premium_interactions?.percent_remaining; + if (premiumRemaining !== undefined && premiumRemaining !== null) { + windows.push({ + label: "Premium", + usedPercent: Math.max(0, Math.min(100, 100 - premiumRemaining)), + }); + } + const chatRemaining = data.quota_snapshots?.chat?.percent_remaining; + if (chatRemaining !== undefined && chatRemaining !== null) { + windows.push({ label: "Chat", usedPercent: Math.max(0, Math.min(100, 100 - chatRemaining)) }); + } + return { + provider: "github-copilot", + displayName: PROVIDER_LABELS["github-copilot"], + windows, + plan: data.copilot_plan, + }; +} + +async function fetchProviderUsageSnapshotFallback(params: { + auth: ProviderAuth; + timeoutMs: number; + fetchFn: typeof fetch; +}): Promise { + switch (params.auth.provider) { + case "anthropic": + return await fetchClaudeUsage(params.auth.token, params.timeoutMs, params.fetchFn); + case "github-copilot": + return await fetchCopilotUsageFallback(params.auth.token, params.timeoutMs, params.fetchFn); + case "google-gemini-cli": + return await fetchGeminiUsage( + params.auth.token, + params.timeoutMs, + params.fetchFn, + "google-gemini-cli", + ); + case "openai-codex": + return await fetchCodexUsage( + params.auth.token, + params.auth.accountId, + params.timeoutMs, + params.fetchFn, + ); + case "zai": + return await fetchZaiUsage(params.auth.token, params.timeoutMs, params.fetchFn); + case "minimax": + return await fetchMinimaxUsage(params.auth.token, params.timeoutMs, params.fetchFn); + case "xiaomi": + return { + provider: "xiaomi", + displayName: PROVIDER_LABELS.xiaomi, + windows: [], + }; + default: + return { + provider: params.auth.provider, + displayName: PROVIDER_LABELS[params.auth.provider], + windows: [], + error: "Unsupported provider", + }; + } +} + type UsageSummaryOptions = { now?: number; timeoutMs?: number; @@ -56,12 +156,11 @@ async function fetchProviderUsageSnapshot(params: { if (pluginSnapshot) { return pluginSnapshot; } - return { - provider: params.auth.provider, - displayName: PROVIDER_LABELS[params.auth.provider], - windows: [], - error: "Unsupported provider", - }; + return await fetchProviderUsageSnapshotFallback({ + auth: params.auth, + timeoutMs: params.timeoutMs, + fetchFn: params.fetchFn, + }); } export async function loadProviderUsageSummary( diff --git a/src/infra/secret-file.ts b/src/infra/secret-file.ts index d62fb326d6b..0d10e605ce5 100644 --- a/src/infra/secret-file.ts +++ b/src/infra/secret-file.ts @@ -22,6 +22,10 @@ export type SecretFileReadResult = error?: unknown; }; +function normalizeSecretReadError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + export function loadSecretFileSync( filePath: string, label: string, @@ -39,11 +43,12 @@ export function loadSecretFileSync( try { previewStat = fs.lstatSync(resolvedPath); } catch (error) { + const normalized = normalizeSecretReadError(error); return { ok: false, resolvedPath, - error, - message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(error)}`, + error: normalized, + message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`, }; } @@ -75,8 +80,9 @@ export function loadSecretFileSync( maxBytes, }); if (!opened.ok) { - const error = - opened.reason === "validation" ? new Error("security validation failed") : opened.error; + const error = normalizeSecretReadError( + opened.reason === "validation" ? new Error("security validation failed") : opened.error, + ); return { ok: false, resolvedPath, @@ -97,11 +103,12 @@ export function loadSecretFileSync( } return { ok: true, secret, resolvedPath }; } catch (error) { + const normalized = normalizeSecretReadError(error); return { ok: false, resolvedPath, - error, - message: `Failed to read ${label} file at ${resolvedPath}: ${String(error)}`, + error: normalized, + message: `Failed to read ${label} file at ${resolvedPath}: ${String(normalized)}`, }; } finally { fs.closeSync(opened.fd); diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts new file mode 100644 index 00000000000..94332c5b307 --- /dev/null +++ b/src/infra/tsdown-config.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import tsdownConfig from "../../tsdown.config.ts"; + +type TsdownConfigEntry = { + entry?: Record | string[]; + outDir?: string; +}; + +function asConfigArray(config: unknown): TsdownConfigEntry[] { + return Array.isArray(config) ? (config as TsdownConfigEntry[]) : [config as TsdownConfigEntry]; +} + +function entryKeys(config: TsdownConfigEntry): string[] { + if (!config.entry || Array.isArray(config.entry)) { + return []; + } + return Object.keys(config.entry); +} + +describe("tsdown config", () => { + it("keeps core, plugin runtime, plugin-sdk, bundled plugins, and bundled hooks in one dist graph", () => { + const configs = asConfigArray(tsdownConfig); + const distGraphs = configs.filter((config) => { + const keys = entryKeys(config); + return ( + keys.includes("index") || + keys.includes("plugins/runtime/index") || + keys.includes("plugin-sdk/index") || + keys.includes("extensions/openai/index") || + keys.includes("bundled/boot-md/handler") + ); + }); + + expect(distGraphs).toHaveLength(1); + expect(entryKeys(distGraphs[0])).toEqual( + expect.arrayContaining([ + "index", + "plugins/runtime/index", + "plugin-sdk/index", + "extensions/openai/index", + "bundled/boot-md/handler", + ]), + ); + }); + + it("does not emit plugin-sdk or hooks from a separate dist graph", () => { + const configs = asConfigArray(tsdownConfig); + + expect(configs.some((config) => config.outDir === "dist/plugin-sdk")).toBe(false); + expect( + configs.some((config) => + Array.isArray(config.entry) + ? config.entry.some((entry) => entry.includes("src/hooks/")) + : false, + ), + ).toBe(false); + }); +}); diff --git a/src/infra/warning-filter.test.ts b/src/infra/warning-filter.test.ts index da4b9dad163..ad3a69571f0 100644 --- a/src/infra/warning-filter.test.ts +++ b/src/infra/warning-filter.test.ts @@ -137,10 +137,7 @@ describe("warning filter", () => { seenWarnings.find((warning) => warning.code === "OPENCLAW_TEST_WARNING"), ).toBeDefined(); expect( - seenWarnings.find( - (warning) => - warning.code === "DEP0040" && warning.message === "The punycode module is deprecated.", - ), + seenWarnings.find((warning) => warning.message === "The punycode module is deprecated."), ).toBeDefined(); expect(stderrWrites.join("")).toContain("Visible warning"); } finally { diff --git a/src/infra/warning-filter.ts b/src/infra/warning-filter.ts index 40863222885..d3117e1da55 100644 --- a/src/infra/warning-filter.ts +++ b/src/infra/warning-filter.ts @@ -75,6 +75,20 @@ export function installProcessWarningFilter(): void { if (shouldIgnoreWarning(normalizeWarningArgs(args))) { return; } + if ( + args[0] instanceof Error && + args[1] && + typeof args[1] === "object" && + !Array.isArray(args[1]) + ) { + const warning = args[0]; + const emitted = Object.assign(new Error(warning.message), { + name: warning.name, + code: (warning as Error & { code?: string }).code, + }); + process.emit("warning", emitted); + return; + } return Reflect.apply(originalEmitWarning, process, args); }) as typeof process.emitWarning; diff --git a/src/logging/logger.browser-import.test.ts b/src/logging/logger.browser-import.test.ts new file mode 100644 index 00000000000..5704770d3ed --- /dev/null +++ b/src/logging/logger.browser-import.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +type LoggerModule = typeof import("./logger.js"); + +const originalGetBuiltinModule = ( + process as NodeJS.Process & { getBuiltinModule?: (id: string) => unknown } +).getBuiltinModule; + +async function importBrowserSafeLogger(params?: { + resolvePreferredOpenClawTmpDir?: ReturnType; +}): Promise<{ + module: LoggerModule; + resolvePreferredOpenClawTmpDir: ReturnType; +}> { + vi.resetModules(); + const resolvePreferredOpenClawTmpDir = + params?.resolvePreferredOpenClawTmpDir ?? + vi.fn(() => { + throw new Error("resolvePreferredOpenClawTmpDir should not run during browser-safe import"); + }); + + vi.doMock("../infra/tmp-openclaw-dir.js", async () => { + const actual = await vi.importActual( + "../infra/tmp-openclaw-dir.js", + ); + return { + ...actual, + resolvePreferredOpenClawTmpDir, + }; + }); + + Object.defineProperty(process, "getBuiltinModule", { + configurable: true, + value: undefined, + }); + + const module = await import("./logger.js"); + return { module, resolvePreferredOpenClawTmpDir }; +} + +describe("logging/logger browser-safe import", () => { + afterEach(() => { + vi.resetModules(); + vi.doUnmock("../infra/tmp-openclaw-dir.js"); + Object.defineProperty(process, "getBuiltinModule", { + configurable: true, + value: originalGetBuiltinModule, + }); + }); + + it("does not resolve the preferred temp dir at import time when node fs is unavailable", async () => { + const { module, resolvePreferredOpenClawTmpDir } = await importBrowserSafeLogger(); + + expect(resolvePreferredOpenClawTmpDir).not.toHaveBeenCalled(); + expect(module.DEFAULT_LOG_DIR).toBe("/tmp/openclaw"); + expect(module.DEFAULT_LOG_FILE).toBe("/tmp/openclaw/openclaw.log"); + }); + + it("disables file logging when imported in a browser-like environment", async () => { + const { module, resolvePreferredOpenClawTmpDir } = await importBrowserSafeLogger(); + + expect(module.getResolvedLoggerSettings()).toMatchObject({ + level: "silent", + file: "/tmp/openclaw/openclaw.log", + }); + expect(module.isFileLogLevelEnabled("info")).toBe(false); + expect(() => module.getLogger().info("browser-safe")).not.toThrow(); + expect(resolvePreferredOpenClawTmpDir).not.toHaveBeenCalled(); + }); +}); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 47e5624dc20..d73009fc696 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -3,7 +3,10 @@ import path from "node:path"; import { Logger as TsLogger } from "tslog"; import { getCommandPathWithRootOptions } from "../cli/argv.js"; import type { OpenClawConfig } from "../config/types.js"; -import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import { + POSIX_OPENCLAW_TMP_DIR, + resolvePreferredOpenClawTmpDir, +} from "../infra/tmp-openclaw-dir.js"; import { readLoggingConfig } from "./config.js"; import type { ConsoleStyle } from "./console.js"; import { resolveEnvLogLevelOverride } from "./env-log-level.js"; @@ -12,7 +15,27 @@ import { resolveNodeRequireFromMeta } from "./node-require.js"; import { loggingState } from "./state.js"; import { formatLocalIsoWithOffset } from "./timestamps.js"; -export const DEFAULT_LOG_DIR = resolvePreferredOpenClawTmpDir(); +type ProcessWithBuiltinModule = NodeJS.Process & { + getBuiltinModule?: (id: string) => unknown; +}; + +function canUseNodeFs(): boolean { + const getBuiltinModule = (process as ProcessWithBuiltinModule).getBuiltinModule; + if (typeof getBuiltinModule !== "function") { + return false; + } + try { + return getBuiltinModule("fs") !== undefined; + } catch { + return false; + } +} + +function resolveDefaultLogDir(): string { + return canUseNodeFs() ? resolvePreferredOpenClawTmpDir() : POSIX_OPENCLAW_TMP_DIR; +} + +export const DEFAULT_LOG_DIR = resolveDefaultLogDir(); export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "openclaw.log"); // legacy single-file path const LOG_PREFIX = "openclaw"; @@ -71,6 +94,14 @@ function canUseSilentVitestFileLogFastPath(envLevel: LogLevel | undefined): bool } function resolveSettings(): ResolvedSettings { + if (!canUseNodeFs()) { + return { + level: "silent", + file: DEFAULT_LOG_FILE, + maxFileBytes: DEFAULT_MAX_LOG_FILE_BYTES, + }; + } + const envLevel = resolveEnvLogLevelOverride(); // Test runs default file logs to silent. Skip config reads and fallback load in the // common case to avoid pulling heavy config/schema stacks on startup. 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-internal/accounts.ts b/src/plugin-sdk-internal/accounts.ts new file mode 100644 index 00000000000..853d41c5f42 --- /dev/null +++ b/src/plugin-sdk-internal/accounts.ts @@ -0,0 +1,8 @@ +export type { OpenClawConfig } from "../config/config.js"; + +export { createAccountActionGate } from "../channels/plugins/account-action-gate.js"; +export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; +export { normalizeChatType } from "../channels/chat-type.js"; +export { resolveAccountEntry } from "../routing/account-lookup.js"; +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +export { normalizeE164, pathExists, resolveUserPath } from "../utils.js"; diff --git a/src/plugin-sdk-internal/setup.ts b/src/plugin-sdk-internal/setup.ts new file mode 100644 index 00000000000..6caf9253e14 --- /dev/null +++ b/src/plugin-sdk-internal/setup.ts @@ -0,0 +1,37 @@ +export type { OpenClawConfig } from "../config/config.js"; +export type { WizardPrompter } from "../wizard/prompts.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; +export type { ChannelSetupDmPolicy } from "../channels/plugins/setup-wizard-types.js"; +export type { + ChannelSetupWizard, + ChannelSetupWizardAllowFromEntry, +} from "../channels/plugins/setup-wizard.js"; + +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +export { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../channels/plugins/setup-helpers.js"; +export { + normalizeAllowFromEntries, + noteChannelLookupFailure, + noteChannelLookupSummary, + parseMentionOrPrefixedId, + parseSetupEntriesAllowingWildcard, + patchChannelConfigForAccount, + promptLegacyChannelAllowFrom, + promptParsedAllowFromForScopedChannel, + promptResolvedAllowFrom, + resolveSetupAccountId, + setAccountGroupPolicyForChannel, + setChannelDmPolicyWithAllowFrom, + setLegacyChannelDmPolicyWithAllowFrom, + setSetupChannelEnabled, + splitSetupEntries, +} from "../channels/plugins/setup-wizard-helpers.js"; +export { detectBinary } from "../commands/onboard-helpers.js"; +export { installSignalCli } from "../commands/signal-install.js"; +export { formatCliCommand } from "../cli/command-format.js"; +export { formatDocsLink } from "../terminal/links.js"; +export { hasConfiguredSecretInput } from "../config/types.secrets.js"; +export { normalizeE164, pathExists } from "../utils.js"; 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..00621521067 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -1,6 +1,5 @@ export type { AnyAgentTool, - OpenClawPluginApi, OpenClawPluginConfigSchema, ProviderDiscoveryContext, ProviderCatalogContext, @@ -22,6 +21,7 @@ export type { ProviderResolveDynamicModelContext, ProviderNormalizeResolvedModelContext, ProviderRuntimeModel, + SpeechProviderPlugin, ProviderThinkingPolicyContext, ProviderWrapStreamFnContext, OpenClawPluginService, @@ -31,29 +31,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 +38,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 +55,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/index.test.ts b/src/plugin-sdk/index.test.ts index d634f80ce66..334f4831853 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -2,10 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { build } from "tsdown"; import { describe, expect, it } from "vitest"; import { - buildPluginSdkEntrySources, buildPluginSdkPackageExports, buildPluginSdkSpecifiers, pluginSdkEntrypoints, @@ -119,24 +117,16 @@ describe("plugin-sdk exports", () => { }); it("emits importable bundled subpath entries", { timeout: 240_000 }, async () => { - const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-build-")); const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-consumer-")); + const repoDistDir = path.join(process.cwd(), "dist"); try { - await build({ - clean: true, - config: false, - dts: false, - entry: buildPluginSdkEntrySources(), - env: { NODE_ENV: "production" }, - fixedExtension: false, - logLevel: "error", - outDir, - platform: "node", - }); + await expect(fs.access(path.join(repoDistDir, "plugin-sdk"))).resolves.toBeUndefined(); for (const entry of pluginSdkEntrypoints) { - const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href); + const module = await import( + pathToFileURL(path.join(repoDistDir, "plugin-sdk", `${entry}.js`)).href + ); expect(module).toBeTypeOf("object"); } @@ -144,8 +134,8 @@ describe("plugin-sdk exports", () => { const consumerDir = path.join(fixtureDir, "consumer"); const consumerEntry = path.join(consumerDir, "import-plugin-sdk.mjs"); - await fs.mkdir(path.join(packageDir, "dist"), { recursive: true }); - await fs.symlink(outDir, path.join(packageDir, "dist", "plugin-sdk"), "dir"); + await fs.mkdir(packageDir, { recursive: true }); + await fs.symlink(repoDistDir, path.join(packageDir, "dist"), "dir"); await fs.writeFile( path.join(packageDir, "package.json"), JSON.stringify( @@ -178,7 +168,6 @@ describe("plugin-sdk exports", () => { Object.fromEntries(pluginSdkSpecifiers.map((specifier: string) => [specifier, "object"])), ); } finally { - await fs.rm(outDir, { recursive: true, force: true }); await fs.rm(fixtureDir, { recursive: true, force: true }); } }); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index e43be3bfadd..07b51661d2d 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -1,4 +1,5 @@ export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; +export { createAccountActionGate } from "../channels/plugins/account-action-gate.js"; export { CHANNEL_MESSAGE_ACTION_NAMES } from "../channels/plugins/message-action-names.js"; export { BLUEBUBBLES_ACTIONS, @@ -61,6 +62,27 @@ export type { BaseTokenResolution, } from "../channels/plugins/types.js"; export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export type { + ChannelSetupConfigureContext, + ChannelSetupDmPolicy, + ChannelSetupInteractiveContext, + ChannelSetupPlugin, + ChannelSetupResult, + ChannelSetupStatus, + ChannelSetupStatusContext, + ChannelSetupWizardAdapter, +} from "../channels/plugins/setup-wizard-types.js"; +export type { + ChannelSetupWizard, + ChannelSetupWizardAllowFromEntry, + ChannelSetupWizardCredential, + ChannelSetupWizardCredentialState, + ChannelSetupWizardFinalize, + ChannelSetupWizardGroupAccess, + ChannelSetupWizardPrepare, + ChannelSetupWizardStatus, + ChannelSetupWizardTextInput, +} from "../channels/plugins/setup-wizard.js"; export type { AcpRuntimeCapabilities, AcpRuntimeControl, @@ -118,6 +140,7 @@ export type { ProviderResolveDynamicModelContext, ProviderNormalizeResolvedModelContext, ProviderRuntimeModel, + SpeechProviderPlugin, ProviderThinkingPolicyContext, ProviderWrapStreamFnContext, } from "../plugins/types.js"; @@ -222,6 +245,21 @@ export { createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { + normalizeAllowFromEntries, + noteChannelLookupFailure, + noteChannelLookupSummary, + parseMentionOrPrefixedId, + parseSetupEntriesAllowingWildcard, + patchChannelConfigForAccount, + promptLegacyChannelAllowFrom, + promptParsedAllowFromForScopedChannel, + promptResolvedAllowFrom, + resolveSetupAccountId, + setAccountGroupPolicyForChannel, + setChannelDmPolicyWithAllowFrom, + setLegacyChannelDmPolicyWithAllowFrom, + setSetupChannelEnabled, + splitSetupEntries, promptSingleChannelSecretInput, type SingleChannelSecretInputPromptResult, } from "../channels/plugins/setup-wizard-helpers.js"; @@ -356,6 +394,7 @@ export { listConfiguredAccountIds, resolveAccountWithDefaultFallback, } from "./account-resolution.js"; +export { resolveAccountEntry } from "../routing/account-lookup.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; export { extractToolSend } from "./tool-send.js"; @@ -385,7 +424,10 @@ export { resolveRuntimeEnv, resolveRuntimeEnvWithUnavailableExit, } from "./runtime.js"; +export { detectBinary } from "../commands/onboard-helpers.js"; +export { installSignalCli } from "../commands/signal-install.js"; export { chunkTextForOutbound } from "./text-chunking.js"; +export { resolveTextChunkLimit } from "../auto-reply/chunk.js"; export { readBooleanParam } from "./boolean-param.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; @@ -420,6 +462,7 @@ export type { TailscaleStatusCommandRunner, } from "../shared/tailscale-status.js"; export type { ChatType } from "../channels/chat-type.js"; +export { normalizeChatType } from "../channels/chat-type.js"; /** @deprecated Use ChatType instead */ export type { RoutePeerKind } from "../routing/resolve-route.js"; export { resolveAckReaction } from "../agents/identity.js"; @@ -453,6 +496,7 @@ export type { PersistentDedupeOptions, } from "./persistent-dedupe.js"; export { formatErrorMessage } from "../infra/errors.js"; +export { resolveFetch } from "../infra/fetch.js"; export { formatUtcTimestamp, formatZonedTimestamp, @@ -619,6 +663,7 @@ export { readStringParam, } from "../agents/tools/common.js"; export { formatDocsLink } from "../terminal/links.js"; +export { formatCliCommand } from "../cli/command-format.js"; export { DM_GROUP_ACCESS_REASON, readStoreAllowFromForDmPolicy, @@ -630,7 +675,23 @@ export { } from "../security/dm-policy-shared.js"; export type { DmGroupAccessReasonCode } from "../security/dm-policy-shared.js"; export type { HookEntry } from "../hooks/types.js"; -export { clamp, escapeRegExp, normalizeE164, safeParseJson, sleep } from "../utils.js"; +export { + clamp, + escapeRegExp, + isRecord, + normalizeE164, + pathExists, + resolveUserPath, + safeParseJson, + sleep, +} from "../utils.js"; +export { fetchWithTimeout } from "../utils/fetch-timeout.js"; +export { + DEFAULT_SECRET_FILE_MAX_BYTES, + loadSecretFileSync, + readSecretFileSync, + tryReadSecretFileSync, +} from "../infra/secret-file.js"; export { stripAnsi } from "../terminal/ansi.js"; export { missingTargetError } from "../infra/outbound/target-errors.js"; export { registerLogTransport } from "../logging/logger.js"; @@ -656,6 +717,8 @@ export type { DiagnosticWebhookProcessedEvent, DiagnosticWebhookReceivedEvent, } from "../infra/diagnostic-events.js"; +export { loadConfig } from "../config/config.js"; +export { runCommandWithTimeout } from "../process/exec.js"; export { detectMime, extensionForMime, getFileExtension } from "../media/mime.js"; export { extractOriginalFilename } from "../media/store.js"; export { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; 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..8f628bd5e8e 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -69,6 +69,9 @@ function getJiti() { const { createJiti } = require("jiti"); jitiLoader = createJiti(__filename, { interopDefault: true, + // Prefer Node's native sync ESM loader for built dist/plugin-sdk/*.js files + // so local plugins do not create a second transpiled OpenClaw core graph. + tryNative: true, extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], }); return jitiLoader; @@ -169,9 +172,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/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 4822c247323..3c30dbee6be 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -25,6 +25,7 @@ function loadRootAliasWithStubs(options?: { }) { let createJitiCalls = 0; let jitiLoadCalls = 0; + let lastJitiOptions: Record | undefined; const loadedSpecifiers: string[] = []; const monolithicExports = options?.monolithicExports ?? { slowHelper: () => "loaded", @@ -52,8 +53,9 @@ function loadRootAliasWithStubs(options?: { } if (id === "jiti") { return { - createJiti() { + createJiti(_filename: string, jitiOptions?: Record) { createJitiCalls += 1; + lastJitiOptions = jitiOptions; return (specifier: string) => { jitiLoadCalls += 1; loadedSpecifiers.push(specifier); @@ -73,6 +75,9 @@ function loadRootAliasWithStubs(options?: { get jitiLoadCalls() { return jitiLoadCalls; }, + get lastJitiOptions() { + return lastJitiOptions; + }, loadedSpecifiers, }; } @@ -116,6 +121,7 @@ describe("plugin-sdk root alias", () => { expect("slowHelper" in lazyRootSdk).toBe(true); expect(lazyModule.createJitiCalls).toBe(1); expect(lazyModule.jitiLoadCalls).toBe(1); + expect(lazyModule.lastJitiOptions?.tryNative).toBe(true); expect((lazyRootSdk.slowHelper as () => string)()).toBe("loaded"); expect(Object.keys(lazyRootSdk)).toContain("slowHelper"); expect(Object.getOwnPropertyDescriptor(lazyRootSdk, "slowHelper")).toBeDefined(); 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..7b15bcfce97 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,16 +1,32 @@ -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 +49,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", () => { @@ -178,8 +241,4 @@ describe("plugin-sdk subpath exports", () => { const zalo = await import("openclaw/plugin-sdk/zalo"); expect(typeof zalo.resolveClientIp).toBe("function"); }); - - it("exports the extension api bridge", () => { - expect(typeof extensionApi.runEmbeddedPiAgent).toBe("function"); - }); }); diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 397a48fa019..3e1275c1425 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -3,28 +3,30 @@ 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 type { + TelegramAccountConfig, + TelegramActionConfig, + TelegramNetworkConfig, +} from "../config/types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { + PAIRING_APPROVED_MESSAGE, applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { + buildChannelConfigSchema, deleteAccountFromConfigSection, - clearAccountEntryFields, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, 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/voice-call.ts b/src/plugin-sdk/voice-call.ts index c50b979a145..7dea0885862 100644 --- a/src/plugin-sdk/voice-call.ts +++ b/src/plugin-sdk/voice-call.ts @@ -15,5 +15,6 @@ export { requestBodyErrorToText, } from "../infra/http-body.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +export type { SessionEntry } from "../config/sessions/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; export { sleep } from "../utils.js"; 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/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index ef109f4abfb..939580f9cfe 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -4,11 +4,16 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { captureEnv } from "../test-utils/env.js"; +import { isRecord } from "../utils.js"; import { loadEnabledBundleMcpConfig } from "./bundle-mcp.js"; import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; const tempDirs: string[] = []; +function getServerArgs(value: unknown): unknown[] | undefined { + return isRecord(value) && Array.isArray(value.args) ? value.args : undefined; +} + async function createTempDir(prefix: string): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); tempDirs.push(dir); @@ -73,10 +78,18 @@ describe("loadEnabledBundleMcpConfig", () => { cfg: config, }); const resolvedServerPath = await fs.realpath(serverPath); + const loadedServer = loaded.config.mcpServers.bundleProbe; + const loadedArgs = getServerArgs(loadedServer); + const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined; expect(loaded.diagnostics).toEqual([]); - expect(loaded.config.mcpServers.bundleProbe?.command).toBe("node"); - expect(loaded.config.mcpServers.bundleProbe?.args).toEqual([resolvedServerPath]); + expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node"); + expect(loadedArgs).toHaveLength(1); + expect(loadedServerPath).toBeDefined(); + if (!loadedServerPath) { + throw new Error("expected bundled MCP args to include the server path"); + } + expect(await fs.realpath(loadedServerPath)).toBe(resolvedServerPath); } finally { env.restore(); } diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts new file mode 100644 index 00000000000..93f53acaf75 --- /dev/null +++ b/src/plugins/bundled-dir.test.ts @@ -0,0 +1,46 @@ +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; + +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; + } + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("resolveBundledPluginsDir", () => { + it("prefers the staged runtime bundled plugin tree from the package root", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-dir-runtime-"); + fs.mkdirSync(path.join(repoRoot, "dist-runtime", "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); + + expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe( + fs.realpathSync(path.join(repoRoot, "dist-runtime", "extensions")), + ); + }); +}); diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 89d43444640..b69da702a7e 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,25 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): return resolveUserPath(override, env); } + try { + const packageRoots = [ + resolveOpenClawPackageRootSync({ cwd: process.cwd() }), + resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url }), + ].filter( + (entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index, + ); + for (const packageRoot of packageRoots) { + // Local source checkouts stage a runtime-complete bundled plugin tree under + // dist-runtime/. Prefer that over release-shaped dist/extensions. + const runtimeExtensionsDir = path.join(packageRoot, "dist-runtime", "extensions"); + if (fs.existsSync(runtimeExtensionsDir)) { + return runtimeExtensionsDir; + } + } + } 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/auth-choice.contract.test.ts b/src/plugins/contracts/auth-choice.contract.test.ts index fa4f4daa0ad..f6af2bed48e 100644 --- a/src/plugins/contracts/auth-choice.contract.test.ts +++ b/src/plugins/contracts/auth-choice.contract.test.ts @@ -1,7 +1,6 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js"; import { applyAuthChoiceLoadedPluginProvider } from "../../commands/auth-choice.apply.plugin-provider.js"; -import { resolvePreferredProviderForAuthChoice } from "../../commands/auth-choice.preferred-provider.js"; import type { AuthChoice } from "../../commands/onboard-types.js"; import { createAuthTestLifecycle, @@ -13,6 +12,7 @@ import { } from "../../commands/test-wizard-helpers.js"; import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; +import { providerContractRegistry } from "./registry.js"; type ResolvePluginProviders = typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").resolvePluginProviders; @@ -28,6 +28,7 @@ const resolveProviderPluginChoiceMock = vi.hoisted(() => vi.fn vi.fn(async () => {}), ); +const resolvePreferredProviderPluginProvidersMock = vi.hoisted(() => vi.fn()); vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({ loginQwenPortalOAuth: loginQwenPortalOAuthMock, @@ -43,6 +44,18 @@ vi.mock("../../commands/auth-choice.apply.plugin-provider.runtime.js", () => ({ runProviderModelSelectedHook: runProviderModelSelectedHookMock, })); +vi.mock("../../plugins/providers.js", async () => { + const actual = await vi.importActual("../../plugins/providers.js"); + return { + ...actual, + resolvePluginProviders: (...args: unknown[]) => + resolvePreferredProviderPluginProvidersMock(...args), + }; +}); + +const { resolvePreferredProviderForAuthChoice } = + await import("../../commands/auth-choice.preferred-provider.js"); + type StoredAuthProfile = { type?: string; provider?: string; @@ -87,6 +100,15 @@ describe("provider auth-choice contract", () => { lifecycle.setStateDir(env.stateDir); } + beforeEach(() => { + resolvePreferredProviderPluginProvidersMock.mockReset(); + resolvePreferredProviderPluginProvidersMock.mockReturnValue([ + ...new Map( + providerContractRegistry.map((entry) => [entry.provider.id, entry.provider]), + ).values(), + ]); + }); + afterEach(async () => { loginQwenPortalOAuthMock.mockReset(); githubCopilotLoginCommandMock.mockReset(); diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts index 306162b2dcf..16a93d30dbe 100644 --- a/src/plugins/contracts/catalog.contract.test.ts +++ b/src/plugins/contracts/catalog.contract.test.ts @@ -1,11 +1,58 @@ -import { describe, expect, it } from "vitest"; -import { +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { providerContractRegistry } from "./registry.js"; + +function uniqueProviders() { + return [ + ...new Map( + providerContractRegistry.map((entry) => [entry.provider.id, entry.provider]), + ).values(), + ]; +} + +const resolvePluginProvidersMock = vi.fn(); +const resolveOwningPluginIdsForProviderMock = vi.fn(); +const resolveNonBundledProviderPluginIdsMock = vi.fn(); + +vi.mock("../providers.js", () => ({ + resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), + resolveOwningPluginIdsForProvider: (...args: unknown[]) => + resolveOwningPluginIdsForProviderMock(...args), + resolveNonBundledProviderPluginIds: (...args: unknown[]) => + resolveNonBundledProviderPluginIdsMock(...args), +})); + +const { augmentModelCatalogWithProviderPlugins, buildProviderMissingAuthMessageWithPlugin, + resetProviderRuntimeHookCacheForTest, resolveProviderBuiltInModelSuppression, -} from "../provider-runtime.js"; +} = await import("../provider-runtime.js"); describe("provider catalog contract", () => { + beforeEach(() => { + const providers = uniqueProviders(); + const providerIds = [...new Set(providerContractRegistry.map((entry) => entry.pluginId))]; + resetProviderRuntimeHookCacheForTest(); + + resolveOwningPluginIdsForProviderMock.mockReset(); + resolveOwningPluginIdsForProviderMock.mockReturnValue(providerIds); + + resolveNonBundledProviderPluginIdsMock.mockReset(); + resolveNonBundledProviderPluginIdsMock.mockReturnValue([]); + + resolvePluginProvidersMock.mockReset(); + resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => { + const onlyPluginIds = params?.onlyPluginIds; + if (!onlyPluginIds || onlyPluginIds.length === 0) { + return providers; + } + const allowed = new Set(onlyPluginIds); + return providerContractRegistry + .filter((entry) => allowed.has(entry.pluginId)) + .map((entry) => entry.provider); + }); + }); + it("keeps codex-only missing-auth hints wired through the provider runtime", () => { expect( buildProviderMissingAuthMessageWithPlugin({ 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/contracts/wizard.contract.test.ts b/src/plugins/contracts/wizard.contract.test.ts index ee0cd879b25..4ebcedb17d9 100644 --- a/src/plugins/contracts/wizard.contract.test.ts +++ b/src/plugins/contracts/wizard.contract.test.ts @@ -1,25 +1,27 @@ -import { describe, expect, it } from "vitest"; -import { +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProviderPlugin } from "../types.js"; +import { providerContractRegistry } from "./registry.js"; + +function uniqueProviders(): ProviderPlugin[] { + return [ + ...new Map( + providerContractRegistry.map((entry) => [entry.provider.id, entry.provider]), + ).values(), + ]; +} + +const resolvePluginProvidersMock = vi.fn(); + +vi.mock("../providers.js", () => ({ + resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), +})); + +const { buildProviderPluginMethodChoice, resolveProviderModelPickerEntries, resolveProviderPluginChoice, resolveProviderWizardOptions, -} from "../provider-wizard.js"; -import { resolvePluginProviders } from "../providers.js"; -import type { ProviderPlugin } from "../types.js"; -import { providerContractRegistry } from "./registry.js"; - -function createBundledProviderConfig() { - return { - plugins: { - enabled: true, - allow: [...new Set(providerContractRegistry.map((entry) => entry.pluginId))], - slots: { - memory: "none", - }, - }, - }; -} +} = await import("../provider-wizard.js"); function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) { const values: string[] = []; @@ -78,15 +80,24 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) { } describe("provider wizard contract", () => { - it("exposes every registered provider setup choice through the shared wizard layer", () => { - const config = createBundledProviderConfig(); - const providers = resolvePluginProviders({ - config, - env: process.env, - }); + beforeEach(() => { + const providers = uniqueProviders(); + resolvePluginProvidersMock.mockReset(); + resolvePluginProvidersMock.mockReturnValue(providers); + }); + it("exposes every registered provider setup choice through the shared wizard layer", () => { + const providers = uniqueProviders(); const options = resolveProviderWizardOptions({ - config, + config: { + plugins: { + enabled: true, + allow: [...new Set(providerContractRegistry.map((entry) => entry.pluginId))], + slots: { + memory: "none", + }, + }, + }, env: process.env, }); @@ -99,13 +110,9 @@ describe("provider wizard contract", () => { }); it("round-trips every shared wizard choice back to its provider and auth method", () => { - const config = createBundledProviderConfig(); - const providers = resolvePluginProviders({ - config, - env: process.env, - }); + const providers = uniqueProviders(); - for (const option of resolveProviderWizardOptions({ config, env: process.env })) { + for (const option of resolveProviderWizardOptions({ config: {}, env: process.env })) { const resolved = resolveProviderPluginChoice({ providers, choice: option.value, @@ -117,16 +124,8 @@ describe("provider wizard contract", () => { }); it("exposes every registered model-picker entry through the shared wizard layer", () => { - const config = createBundledProviderConfig(); - const providers = resolvePluginProviders({ - config, - env: process.env, - }); - - const entries = resolveProviderModelPickerEntries({ - config, - env: process.env, - }); + const providers = uniqueProviders(); + const entries = resolveProviderModelPickerEntries({ config: {}, env: process.env }); expect( entries.map((entry) => entry.value).toSorted((left, right) => left.localeCompare(right)), diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 82867213fdd..808ba4c8cb7 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -297,22 +297,6 @@ function createPluginSdkAliasFixture(params?: { return { root, srcFile, distFile }; } -function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?: string }) { - const root = makeTempDir(); - const srcFile = path.join(root, "src", "extensionAPI.ts"); - const distFile = path.join(root, "dist", "extensionAPI.js"); - mkdirSafe(path.dirname(srcFile)); - mkdirSafe(path.dirname(distFile)); - fs.writeFileSync( - path.join(root, "package.json"), - JSON.stringify({ name: "openclaw", type: "module" }, null, 2), - "utf-8", - ); - fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8"); - fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8"); - return { root, srcFile, distFile }; -} - function createPluginRuntimeAliasFixture(params?: { srcBody?: string; distBody?: string }) { const root = makeTempDir(); const srcFile = path.join(root, "src", "plugins", "runtime", "index.ts"); @@ -348,7 +332,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 +352,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 +377,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 +388,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 +421,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 +439,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"); @@ -714,6 +713,68 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s expect(getGlobalHookRunner()).toBeNull(); }); + it("only publishes plugin commands to the global registry during activating loads", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "command-plugin", + filename: "command-plugin.cjs", + body: `module.exports = { + id: "command-plugin", + register(api) { + api.registerCommand({ + name: "pair", + description: "Pair device", + acceptsArgs: true, + handler: async ({ args }) => ({ text: \`paired:\${args ?? ""}\` }), + }); + }, + };`, + }); + const { clearPluginCommands, getPluginCommandSpecs } = await import("./commands.js"); + + clearPluginCommands(); + + const scoped = loadOpenClawPlugins({ + cache: false, + activate: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["command-plugin"], + }, + }, + onlyPluginIds: ["command-plugin"], + }); + + expect(scoped.plugins.find((entry) => entry.id === "command-plugin")?.status).toBe("loaded"); + expect(scoped.commands.map((entry) => entry.command.name)).toEqual(["pair"]); + expect(getPluginCommandSpecs("telegram")).toEqual([]); + + const active = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["command-plugin"], + }, + }, + onlyPluginIds: ["command-plugin"], + }); + + expect(active.plugins.find((entry) => entry.id === "command-plugin")?.status).toBe("loaded"); + expect(getPluginCommandSpecs("telegram")).toEqual([ + { + name: "pair", + description: "Pair device", + acceptsArgs: true, + }, + ]); + + clearPluginCommands(); + }); + it("throws when activate:false is used without cache:false", () => { expect(() => loadOpenClawPlugins({ activate: false })).toThrow( "activate:false requires cache:false", @@ -927,6 +988,44 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s expect(third).toBe(second); }); + it("does not reuse cached registries across gateway subagent binding modes", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "cache-gateway-bindable", + filename: "cache-gateway-bindable.cjs", + body: `module.exports = { id: "cache-gateway-bindable", register() {} };`, + }); + + const options = { + workspaceDir: plugin.dir, + config: { + plugins: { + allow: ["cache-gateway-bindable"], + load: { + paths: [plugin.file], + }, + }, + }, + }; + + const defaultRegistry = loadOpenClawPlugins(options); + const gatewayBindableRegistry = loadOpenClawPlugins({ + ...options, + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }); + const gatewayBindableAgain = loadOpenClawPlugins({ + ...options, + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }); + + expect(gatewayBindableRegistry).not.toBe(defaultRegistry); + expect(gatewayBindableAgain).toBe(gatewayBindableRegistry); + }); + it("evicts least recently used registries when the loader cache exceeds its cap", () => { useNoBundledPlugins(); const plugin = writePlugin({ @@ -2015,6 +2114,225 @@ 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, + token: "configured", + }, + }, + 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, + token: "configured", + }, + }, + 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 +3103,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 +3122,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"); @@ -2949,6 +3273,16 @@ module.exports = { expect(resolved).toBe(distFile); }); + it("configures the plugin loader jiti boundary to prefer native dist modules", () => { + const options = __testing.buildPluginLoaderJitiOptions({}); + + expect(options.tryNative).toBe(true); + expect(options.interopDefault).toBe(true); + expect(options.extensions).toContain(".js"); + expect(options.extensions).toContain(".ts"); + expect("alias" in options).toBe(false); + }); + it("prefers src root-alias shim when loader runs from src in non-production", () => { const { root, srcFile } = createPluginSdkAliasFixture({ srcFile: "root-alias.cjs", @@ -2967,26 +3301,6 @@ module.exports = { expect(resolved).toBe(srcFile); }); - it("prefers dist extension-api alias when loader runs from dist", () => { - const { root, distFile } = createExtensionApiAliasFixture(); - - const resolved = __testing.resolveExtensionApiAlias({ - modulePath: path.join(root, "dist", "plugins", "loader.js"), - }); - expect(resolved).toBe(distFile); - }); - - it("prefers src extension-api alias when loader runs from src in non-production", () => { - const { root, srcFile } = createExtensionApiAliasFixture(); - - const resolved = withEnv({ NODE_ENV: undefined }, () => - __testing.resolveExtensionApiAlias({ - modulePath: path.join(root, "src", "plugins", "loader.ts"), - }), - ); - expect(resolved).toBe(srcFile); - }); - it("resolves plugin-sdk alias from package root when loader runs from transpiler cache path", () => { const { root, srcFile } = createPluginSdkAliasFixture(); @@ -3001,16 +3315,13 @@ module.exports = { expect(resolved).toBe(srcFile); }); - it("resolves extension-api alias from package root when loader runs from transpiler cache path", () => { - const { root, srcFile } = createExtensionApiAliasFixture(); + it("prefers dist plugin runtime module when loader runs from dist", () => { + const { root, distFile } = createPluginRuntimeAliasFixture(); - const resolved = withEnv({ NODE_ENV: undefined }, () => - __testing.resolveExtensionApiAlias({ - modulePath: "/tmp/tsx-cache/openclaw-loader.js", - argv1: path.join(root, "openclaw.mjs"), - }), - ); - expect(resolved).toBe(srcFile); + const resolved = __testing.resolvePluginRuntimeModulePath({ + modulePath: path.join(root, "dist", "plugins", "loader.js"), + }); + expect(resolved).toBe(distFile); }); it("resolves plugin runtime module from package root when loader runs from transpiler cache path", () => { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index da9bcd3e993..a2e05fc06b9 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -54,12 +54,33 @@ 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", + "agent", + "subagent", + "system", + "media", + "tts", + "stt", + "tools", + "channel", + "events", + "logging", + "state", + "modelAuth", +] as const satisfies readonly (keyof PluginRuntime)[]; export function clearPluginLoaderCache(): void { registryCache.clear(); @@ -177,33 +198,20 @@ const resolvePluginSdkAliasFile = (params: { const resolvePluginSdkAlias = (): string | null => resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" }); -const resolveExtensionApiAlias = (params: LoaderModuleResolveParams = {}): string | null => { - try { - const modulePath = resolveLoaderModulePath(params); - const packageRoot = resolveLoaderPackageRoot({ ...params, modulePath }); - if (!packageRoot) { - return null; - } - - const orderedKinds = resolvePluginSdkAliasCandidateOrder({ - modulePath, - isProduction: process.env.NODE_ENV === "production", - }); - const candidateMap = { - src: path.join(packageRoot, "src", "extensionAPI.ts"), - dist: path.join(packageRoot, "dist", "extensionAPI.js"), - } as const; - for (const kind of orderedKinds) { - const candidate = candidateMap[kind]; - if (fs.existsSync(candidate)) { - return candidate; - } - } - } catch { - // ignore - } - return null; -}; +function buildPluginLoaderJitiOptions(aliasMap: Record) { + return { + interopDefault: true, + // Prefer Node's native sync ESM loader for built dist/*.js modules so + // bundled plugins and plugin-sdk subpaths stay on the canonical module graph. + tryNative: true, + extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], + ...(Object.keys(aliasMap).length > 0 + ? { + alias: aliasMap, + } + : {}), + }; +} function resolvePluginRuntimeModulePath(params: LoaderModuleResolveParams = {}): string | null { try { @@ -280,9 +288,9 @@ const resolvePluginSdkScopedAliasMap = (): Record => { }; export const __testing = { + buildPluginLoaderJitiOptions, listPluginSdkAliasCandidates, listPluginSdkExportedSubpaths, - resolveExtensionApiAlias, resolvePluginSdkAliasCandidateOrder, resolvePluginSdkAliasFile, resolvePluginRuntimeModulePath, @@ -321,6 +329,8 @@ function buildCacheKey(params: { env: NodeJS.ProcessEnv; onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; + preferSetupRuntimeForChannelPlugins?: boolean; + runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable"; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, @@ -345,11 +355,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}::${params.runtimeSubagentMode ?? "default"}`; } function normalizeScopedPluginIds(ids?: string[]): string[] | undefined { @@ -430,12 +442,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), ); @@ -474,6 +494,7 @@ function createPluginRecord(params: { hookNames: [], channelIds: [], providerIds: [], + speechProviderIds: [], webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], @@ -785,6 +806,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 +819,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi env, onlyPluginIds, includeSetupOnlyChannelPlugins, + preferSetupRuntimeForChannelPlugins, + runtimeSubagentMode: + options.runtimeOptions?.allowGatewaySubagentBinding === true + ? "gateway-bindable" + : options.runtimeOptions?.subagent + ? "explicit" + : "default", }); const cacheEnabled = options.cache !== false; if (cacheEnabled) { @@ -823,21 +852,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi return jitiLoader; } const pluginSdkAlias = resolvePluginSdkAlias(); - const extensionApiAlias = resolveExtensionApiAlias(); const aliasMap = { - ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), ...resolvePluginSdkScopedAliasMap(), }; - jitiLoader = createJiti(import.meta.url, { - interopDefault: true, - extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], - ...(Object.keys(aliasMap).length > 0 - ? { - alias: aliasMap, - } - : {}), - }); + jitiLoader = createJiti(import.meta.url, buildPluginLoaderJitiOptions(aliasMap)); return jitiLoader; }; @@ -870,6 +889,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 +913,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 +1070,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/marketplace.test.ts b/src/plugins/marketplace.test.ts index 14d3bda0323..92918e256d4 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -45,21 +45,22 @@ describe("marketplace plugins", () => { const { listMarketplacePlugins } = await import("./marketplace.js"); const result = await listMarketplacePlugins({ marketplace: rootDir }); - expect(result).toEqual({ - ok: true, - sourceLabel: expect.stringContaining(".claude-plugin/marketplace.json"), - manifest: { - name: "Example Marketplace", - version: "1.0.0", - plugins: [ - { - name: "frontend-design", - version: "0.1.0", - description: "Design system bundle", - source: { kind: "path", path: "./plugins/frontend-design" }, - }, - ], - }, + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("expected marketplace listing to succeed"); + } + expect(result.sourceLabel.replaceAll("\\", "/")).toContain(".claude-plugin/marketplace.json"); + expect(result.manifest).toEqual({ + name: "Example Marketplace", + version: "1.0.0", + plugins: [ + { + name: "frontend-design", + version: "0.1.0", + description: "Design system bundle", + source: { kind: "path", path: "./plugins/frontend-design" }, + }, + ], }); }); }); 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..561154196f0 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -1,7 +1,16 @@ 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 { resolvePluginCacheInputs } from "./roots.js"; import type { ProviderAuthDoctorHintContext, ProviderAugmentModelCatalogContext, @@ -33,19 +42,113 @@ 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[]; + env?: NodeJS.ProcessEnv; +}) { + const { roots } = resolvePluginCacheInputs({ + workspaceDir: params.workspaceDir, + env: params.env, + }); + return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${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, + env, + }); + 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 +157,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 +368,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 +387,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/registry.ts b/src/plugins/registry.ts index fabf9fa1069..231e6f267aa 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -46,6 +46,7 @@ import type { PluginHookName, PluginHookHandlerMap, PluginHookRegistration as TypedPluginHookRegistration, + SpeechProviderPlugin, WebSearchProviderPlugin, } from "./types.js"; @@ -110,6 +111,14 @@ export type PluginWebSearchProviderRegistration = { rootDir?: string; }; +export type PluginSpeechProviderRegistration = { + pluginId: string; + pluginName?: string; + provider: SpeechProviderPlugin; + source: string; + rootDir?: string; +}; + export type PluginHookRegistration = { pluginId: string; entry: HookEntry; @@ -154,6 +163,7 @@ export type PluginRecord = { hookNames: string[]; channelIds: string[]; providerIds: string[]; + speechProviderIds: string[]; webSearchProviderIds: string[]; gatewayMethods: string[]; cliCommands: string[]; @@ -174,6 +184,7 @@ export type PluginRegistry = { channels: PluginChannelRegistration[]; channelSetups: PluginChannelSetupRegistration[]; providers: PluginProviderRegistration[]; + speechProviders: PluginSpeechProviderRegistration[]; webSearchProviders: PluginWebSearchProviderRegistration[]; gatewayHandlers: GatewayRequestHandlers; httpRoutes: PluginHttpRouteRegistration[]; @@ -219,6 +230,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { channels: [], channelSetups: [], providers: [], + speechProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], @@ -550,6 +562,37 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const registerSpeechProvider = (record: PluginRecord, provider: SpeechProviderPlugin) => { + const id = provider.id.trim(); + if (!id) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "speech provider registration missing id", + }); + return; + } + const existing = registry.speechProviders.find((entry) => entry.provider.id === id); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `speech provider already registered: ${id} (${existing.pluginId})`, + }); + return; + } + record.speechProviderIds.push(id); + registry.speechProviders.push({ + pluginId: record.id, + pluginName: record.name, + provider, + source: record.source, + rootDir: record.rootDir, + }); + }; + const registerWebSearchProvider = (record: PluginRecord, provider: WebSearchProviderPlugin) => { const id = provider.id.trim(); if (!id) { @@ -789,6 +832,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerChannel: (registration) => registerChannel(record, registration, registrationMode), registerProvider: registrationMode === "full" ? (provider) => registerProvider(record, provider) : () => {}, + registerSpeechProvider: + registrationMode === "full" + ? (provider) => registerSpeechProvider(record, provider) + : () => {}, registerWebSearchProvider: registrationMode === "full" ? (provider) => registerWebSearchProvider(record, provider) @@ -862,6 +909,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerTool, registerChannel, registerProvider, + registerSpeechProvider, registerWebSearchProvider, registerGatewayMethod, registerCli, diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 5ec2df28199..dfca1cfaf4a 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { onAgentEvent } from "../../infra/agent-events.js"; import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js"; import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; @@ -9,11 +10,16 @@ vi.mock("../../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); -import { createPluginRuntime } from "./index.js"; +import { + clearGatewaySubagentRuntime, + createPluginRuntime, + setGatewaySubagentRuntime, +} from "./index.js"; describe("plugin runtime command execution", () => { beforeEach(() => { runCommandWithTimeoutMock.mockClear(); + clearGatewaySubagentRuntime(); }); it("exposes runtime.system.runCommandWithTimeout by default", async () => { @@ -54,6 +60,17 @@ describe("plugin runtime command execution", () => { expect(runtime.system.requestHeartbeatNow).toBe(requestHeartbeatNow); }); + it("exposes runtime.agent host helpers", () => { + const runtime = createPluginRuntime(); + expect(runtime.agent.defaults).toEqual({ + model: DEFAULT_MODEL, + provider: DEFAULT_PROVIDER, + }); + expect(typeof runtime.agent.runEmbeddedPiAgent).toBe("function"); + expect(typeof runtime.agent.resolveAgentDir).toBe("function"); + expect(typeof runtime.agent.session.resolveSessionFilePath).toBe("function"); + }); + it("exposes runtime.modelAuth with getApiKeyForModel and resolveApiKeyForProvider", () => { const runtime = createPluginRuntime(); expect(runtime.modelAuth).toBeDefined(); @@ -70,4 +87,37 @@ describe("plugin runtime command execution", () => { // Wrappers should NOT be the same reference as the raw functions expect(runtime.modelAuth.getApiKeyForModel).not.toBe(rawGetApiKey); }); + + it("keeps subagent unavailable by default even after gateway initialization", async () => { + const runtime = createPluginRuntime(); + setGatewaySubagentRuntime({ + run: vi.fn(), + waitForRun: vi.fn(), + getSessionMessages: vi.fn(), + getSession: vi.fn(), + deleteSession: vi.fn(), + }); + + expect(() => runtime.subagent.run({ sessionKey: "s-1", message: "hello" })).toThrow( + "Plugin runtime subagent methods are only available during a gateway request.", + ); + }); + + it("late-binds to the gateway subagent when explicitly enabled", async () => { + const run = vi.fn().mockResolvedValue({ runId: "run-1" }); + const runtime = createPluginRuntime({ allowGatewaySubagentBinding: true }); + + setGatewaySubagentRuntime({ + run, + waitForRun: vi.fn(), + getSessionMessages: vi.fn(), + getSession: vi.fn(), + deleteSession: vi.fn(), + }); + + await expect(runtime.subagent.run({ sessionKey: "s-2", message: "hello" })).resolves.toEqual({ + runId: "run-1", + }); + expect(run).toHaveBeenCalledWith({ sessionKey: "s-2", message: "hello" }); + }); }); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 12d33168cd3..d94825062cd 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -6,6 +6,7 @@ import { import { resolveStateDir } from "../../config/paths.js"; import { transcribeAudioFile } from "../../media-understanding/transcribe-audio.js"; import { textToSpeechTelephony } from "../../tts/tts.js"; +import { createRuntimeAgent } from "./runtime-agent.js"; import { createRuntimeChannel } from "./runtime-channel.js"; import { createRuntimeConfig } from "./runtime-config.js"; import { createRuntimeEvents } from "./runtime-events.js"; @@ -45,15 +46,93 @@ function createUnavailableSubagentRuntime(): PluginRuntime["subagent"] { }; } +// ── Process-global gateway subagent runtime ───────────────────────── +// The gateway creates a real subagent runtime during startup, but gateway-owned +// plugin registries may be loaded (and cached) before the gateway path runs. +// A process-global holder lets explicitly gateway-bindable runtimes resolve the +// active gateway subagent dynamically without changing the default behavior for +// ordinary plugin runtimes. + +const GATEWAY_SUBAGENT_SYMBOL: unique symbol = Symbol.for( + "openclaw.plugin.gatewaySubagentRuntime", +) as unknown as typeof GATEWAY_SUBAGENT_SYMBOL; + +type GatewaySubagentState = { + subagent: PluginRuntime["subagent"] | undefined; +}; + +const gatewaySubagentState: GatewaySubagentState = (() => { + const g = globalThis as typeof globalThis & { + [GATEWAY_SUBAGENT_SYMBOL]?: GatewaySubagentState; + }; + const existing = g[GATEWAY_SUBAGENT_SYMBOL]; + if (existing) { + return existing; + } + const created: GatewaySubagentState = { subagent: undefined }; + g[GATEWAY_SUBAGENT_SYMBOL] = created; + return created; +})(); + +/** + * Set the process-global gateway subagent runtime. + * Called during gateway startup so that gateway-bindable plugin runtimes can + * resolve subagent methods dynamically even when their registry was cached + * before the gateway finished loading plugins. + */ +export function setGatewaySubagentRuntime(subagent: PluginRuntime["subagent"]): void { + gatewaySubagentState.subagent = subagent; +} + +/** + * Reset the process-global gateway subagent runtime. + * Used by tests to avoid leaking gateway state across module reloads. + */ +export function clearGatewaySubagentRuntime(): void { + gatewaySubagentState.subagent = undefined; +} + +/** + * Create a late-binding subagent that resolves to: + * 1. An explicitly provided subagent (from runtimeOptions), OR + * 2. The process-global gateway subagent when the caller explicitly opts in, OR + * 3. The unavailable fallback (throws with a clear error message). + */ +function createLateBindingSubagent( + explicit?: PluginRuntime["subagent"], + allowGatewaySubagentBinding = false, +): PluginRuntime["subagent"] { + if (explicit) { + return explicit; + } + + const unavailable = createUnavailableSubagentRuntime(); + if (!allowGatewaySubagentBinding) { + return unavailable; + } + + return new Proxy(unavailable, { + get(_target, prop, _receiver) { + const resolved = gatewaySubagentState.subagent ?? unavailable; + return Reflect.get(resolved, prop, resolved); + }, + }); +} + export type CreatePluginRuntimeOptions = { subagent?: PluginRuntime["subagent"]; + allowGatewaySubagentBinding?: boolean; }; export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): PluginRuntime { const runtime = { version: resolveVersion(), config: createRuntimeConfig(), - subagent: _options.subagent ?? createUnavailableSubagentRuntime(), + agent: createRuntimeAgent(), + subagent: createLateBindingSubagent( + _options.subagent, + _options.allowGatewaySubagentBinding === true, + ), system: createRuntimeSystem(), media: createRuntimeMedia(), tts: { textToSpeechTelephony }, diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts new file mode 100644 index 00000000000..ae56d1e4bd3 --- /dev/null +++ b/src/plugins/runtime/runtime-agent.ts @@ -0,0 +1,36 @@ +import { resolveAgentDir, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import { resolveAgentIdentity } from "../../agents/identity.js"; +import { resolveThinkingDefault } from "../../agents/model-selection.js"; +import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; +import { ensureAgentWorkspace } from "../../agents/workspace.js"; +import { + loadSessionStore, + resolveSessionFilePath, + resolveStorePath, + saveSessionStore, +} from "../../config/sessions.js"; +import type { PluginRuntime } from "./types.js"; + +export function createRuntimeAgent(): PluginRuntime["agent"] { + return { + defaults: { + model: DEFAULT_MODEL, + provider: DEFAULT_PROVIDER, + }, + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveAgentIdentity, + resolveThinkingDefault, + runEmbeddedPiAgent, + resolveAgentTimeoutMs, + ensureAgentWorkspace, + session: { + resolveStorePath, + loadSessionStore, + saveSessionStore, + resolveSessionFilePath, + }, + }; +} 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-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/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index c25c3afa86b..c1bb753fb11 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -13,6 +13,25 @@ export type PluginRuntimeCore = { loadConfig: typeof import("../../config/config.js").loadConfig; writeConfigFile: typeof import("../../config/config.js").writeConfigFile; }; + agent: { + defaults: { + model: typeof import("../../agents/defaults.js").DEFAULT_MODEL; + provider: typeof import("../../agents/defaults.js").DEFAULT_PROVIDER; + }; + resolveAgentDir: typeof import("../../agents/agent-scope.js").resolveAgentDir; + resolveAgentWorkspaceDir: typeof import("../../agents/agent-scope.js").resolveAgentWorkspaceDir; + resolveAgentIdentity: typeof import("../../agents/identity.js").resolveAgentIdentity; + resolveThinkingDefault: typeof import("../../agents/model-selection.js").resolveThinkingDefault; + runEmbeddedPiAgent: typeof import("../../agents/pi-embedded.js").runEmbeddedPiAgent; + resolveAgentTimeoutMs: typeof import("../../agents/timeout.js").resolveAgentTimeoutMs; + ensureAgentWorkspace: typeof import("../../agents/workspace.js").ensureAgentWorkspace; + session: { + resolveStorePath: typeof import("../../config/sessions.js").resolveStorePath; + loadSessionStore: typeof import("../../config/sessions.js").loadSessionStore; + saveSessionStore: typeof import("../../config/sessions.js").saveSessionStore; + resolveSessionFilePath: typeof import("../../config/sessions.js").resolveSessionFilePath; + }; + }; system: { enqueueSystemEvent: typeof import("../../infra/system-events.js").enqueueSystemEvent; requestHeartbeatNow: typeof import("../../infra/heartbeat-wake.js").requestHeartbeatNow; diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts new file mode 100644 index 00000000000..fe246e8fcfe --- /dev/null +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -0,0 +1,332 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mjs"; +import { discoverOpenClawPlugins } from "./discovery.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; + +const tempDirs: string[] = []; + +function makeRepoRoot(prefix: string): string { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(repoRoot); + return repoRoot; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("stageBundledPluginRuntime", () => { + it("stages bundled dist plugins as runtime wrappers and links plugin-local node_modules", () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-"); + const distPluginDir = path.join(repoRoot, "dist", "extensions", "diffs"); + fs.mkdirSync(path.join(repoRoot, "dist"), { recursive: true }); + const sourcePluginNodeModulesDir = path.join(repoRoot, "extensions", "diffs", "node_modules"); + fs.mkdirSync(distPluginDir, { recursive: true }); + fs.mkdirSync(path.join(sourcePluginNodeModulesDir, "@pierre", "diffs"), { + recursive: true, + }); + fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {}\n", "utf8"); + fs.writeFileSync( + path.join(sourcePluginNodeModulesDir, "@pierre", "diffs", "index.js"), + "export default {}\n", + "utf8", + ); + + stageBundledPluginRuntime({ repoRoot }); + + const runtimePluginDir = path.join(repoRoot, "dist-runtime", "extensions", "diffs"); + expect(fs.existsSync(path.join(runtimePluginDir, "index.js"))).toBe(true); + expect(fs.readFileSync(path.join(runtimePluginDir, "index.js"), "utf8")).toContain( + "../../../dist/extensions/diffs/index.js", + ); + expect(fs.lstatSync(path.join(runtimePluginDir, "node_modules")).isSymbolicLink()).toBe(true); + expect(fs.realpathSync(path.join(runtimePluginDir, "node_modules"))).toBe( + fs.realpathSync(sourcePluginNodeModulesDir), + ); + }); + + it("writes wrappers that forward plugin entry imports into canonical dist files", async () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-chunks-"); + fs.mkdirSync(path.join(repoRoot, "dist", "extensions", "diffs"), { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, "dist", "chunk-abc.js"), + "export const value = 1;\n", + "utf8", + ); + fs.writeFileSync( + path.join(repoRoot, "dist", "extensions", "diffs", "index.js"), + "export { value } from '../../chunk-abc.js';\n", + "utf8", + ); + + stageBundledPluginRuntime({ repoRoot }); + + const runtimeEntryPath = path.join(repoRoot, "dist-runtime", "extensions", "diffs", "index.js"); + expect(fs.readFileSync(runtimeEntryPath, "utf8")).toContain( + "../../../dist/extensions/diffs/index.js", + ); + expect(fs.existsSync(path.join(repoRoot, "dist-runtime", "chunk-abc.js"))).toBe(false); + + const runtimeModule = await import(`${pathToFileURL(runtimeEntryPath).href}?t=${Date.now()}`); + expect(runtimeModule.value).toBe(1); + }); + + it("keeps plugin command registration on the canonical dist graph when loaded from dist-runtime", async () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-commands-"); + const distPluginDir = path.join(repoRoot, "dist", "extensions", "demo"); + const distCommandsDir = path.join(repoRoot, "dist", "plugins"); + fs.mkdirSync(distPluginDir, { recursive: true }); + fs.mkdirSync(distCommandsDir, { recursive: true }); + fs.writeFileSync(path.join(repoRoot, "package.json"), '{ "type": "module" }\n', "utf8"); + fs.writeFileSync( + path.join(distCommandsDir, "commands.js"), + [ + "const registry = globalThis.__openclawTestPluginCommands ??= new Map();", + "export function registerPluginCommand(pluginId, command) {", + " registry.set(`/${command.name.toLowerCase()}`, { ...command, pluginId });", + "}", + "export function clearPluginCommands() {", + " registry.clear();", + "}", + "export function getPluginCommandSpecs(provider) {", + " if (provider && provider !== 'telegram' && provider !== 'discord') return [];", + " return Array.from(registry.values()).map((command) => ({", + " name: command.nativeNames?.[provider] ?? command.nativeNames?.default ?? command.name,", + " description: command.description,", + " acceptsArgs: command.acceptsArgs ?? false,", + " }));", + "}", + "export function matchPluginCommand(commandBody) {", + " const [commandName, ...rest] = commandBody.trim().split(/\\s+/u);", + " const command = registry.get(commandName.toLowerCase());", + " if (!command) return null;", + " return { command, args: rest.length > 0 ? rest.join(' ') : undefined };", + "}", + "export async function executePluginCommand(params) {", + " return params.command.handler({ args: params.args });", + "}", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(distPluginDir, "index.js"), + [ + "import { registerPluginCommand } from '../../plugins/commands.js';", + "", + "export function registerDemoCommand() {", + " registerPluginCommand('demo-plugin', {", + " name: 'pair',", + " description: 'Pair a device',", + " acceptsArgs: true,", + " nativeNames: { telegram: 'pair', discord: 'pair' },", + " handler: async ({ args }) => ({ text: `paired:${args ?? ''}` }),", + " });", + "}", + "", + ].join("\n"), + "utf8", + ); + + stageBundledPluginRuntime({ repoRoot }); + + const runtimeEntryPath = path.join(repoRoot, "dist-runtime", "extensions", "demo", "index.js"); + const canonicalCommandsPath = path.join(repoRoot, "dist", "plugins", "commands.js"); + + expect(fs.existsSync(path.join(repoRoot, "dist-runtime", "plugins", "commands.js"))).toBe( + false, + ); + + const runtimeModule = await import(`${pathToFileURL(runtimeEntryPath).href}?t=${Date.now()}`); + const commandsModule = (await import( + `${pathToFileURL(canonicalCommandsPath).href}?t=${Date.now()}` + )) as { + clearPluginCommands: () => void; + getPluginCommandSpecs: (provider?: string) => Array<{ + name: string; + description: string; + acceptsArgs: boolean; + }>; + matchPluginCommand: (commandBody: string) => { + command: { handler: ({ args }: { args?: string }) => Promise<{ text: string }> }; + args?: string; + } | null; + executePluginCommand: (params: { + command: { handler: ({ args }: { args?: string }) => Promise<{ text: string }> }; + args?: string; + }) => Promise<{ text: string }>; + }; + + commandsModule.clearPluginCommands(); + runtimeModule.registerDemoCommand(); + + expect(commandsModule.getPluginCommandSpecs("telegram")).toEqual([ + { name: "pair", description: "Pair a device", acceptsArgs: true }, + ]); + expect(commandsModule.getPluginCommandSpecs("discord")).toEqual([ + { name: "pair", description: "Pair a device", acceptsArgs: true }, + ]); + + const match = commandsModule.matchPluginCommand("/pair now"); + expect(match).not.toBeNull(); + expect(match?.args).toBe("now"); + await expect( + commandsModule.executePluginCommand({ + command: match!.command, + args: match?.args, + }), + ).resolves.toEqual({ text: "paired:now" }); + }); + + it("copies package metadata files but symlinks other non-js plugin artifacts into the runtime overlay", () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-assets-"); + const distPluginDir = path.join(repoRoot, "dist", "extensions", "diffs"); + fs.mkdirSync(path.join(distPluginDir, "assets"), { recursive: true }); + fs.writeFileSync( + path.join(distPluginDir, "package.json"), + JSON.stringify( + { name: "@openclaw/diffs", openclaw: { extensions: ["./index.js"] } }, + null, + 2, + ), + "utf8", + ); + fs.writeFileSync(path.join(distPluginDir, "openclaw.plugin.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(distPluginDir, "assets", "info.txt"), "ok\n", "utf8"); + + stageBundledPluginRuntime({ repoRoot }); + + const runtimePackagePath = path.join( + repoRoot, + "dist-runtime", + "extensions", + "diffs", + "package.json", + ); + const runtimeManifestPath = path.join( + repoRoot, + "dist-runtime", + "extensions", + "diffs", + "openclaw.plugin.json", + ); + const runtimeAssetPath = path.join( + repoRoot, + "dist-runtime", + "extensions", + "diffs", + "assets", + "info.txt", + ); + + expect(fs.lstatSync(runtimePackagePath).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(runtimePackagePath, "utf8")).toContain('"extensions": ['); + expect(fs.lstatSync(runtimeManifestPath).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(runtimeManifestPath, "utf8")).toBe("{}\n"); + expect(fs.lstatSync(runtimeAssetPath).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(runtimeAssetPath, "utf8")).toBe("ok\n"); + }); + + it("preserves package metadata needed for bundled plugin discovery from dist-runtime", () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-discovery-"); + const distPluginDir = path.join(repoRoot, "dist", "extensions", "demo"); + const runtimeExtensionsDir = path.join(repoRoot, "dist-runtime", "extensions"); + fs.mkdirSync(distPluginDir, { recursive: true }); + fs.writeFileSync( + path.join(distPluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/demo", + openclaw: { + extensions: ["./main.js"], + setupEntry: "./setup.js", + startup: { + deferConfiguredChannelFullLoadUntilAfterListen: true, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + fs.writeFileSync( + path.join(distPluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "demo", + channels: ["demo"], + configSchema: { type: "object" }, + }, + null, + 2, + ), + "utf8", + ); + fs.writeFileSync(path.join(distPluginDir, "main.js"), "export default {};\n", "utf8"); + fs.writeFileSync(path.join(distPluginDir, "setup.js"), "export default {};\n", "utf8"); + + stageBundledPluginRuntime({ repoRoot }); + + const env = { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: runtimeExtensionsDir, + }; + const discovery = discoverOpenClawPlugins({ + env, + cache: false, + }); + const manifestRegistry = loadPluginManifestRegistry({ + env, + cache: false, + candidates: discovery.candidates, + diagnostics: discovery.diagnostics, + }); + const expectedRuntimeMainPath = fs.realpathSync( + path.join(runtimeExtensionsDir, "demo", "main.js"), + ); + const expectedRuntimeSetupPath = fs.realpathSync( + path.join(runtimeExtensionsDir, "demo", "setup.js"), + ); + + expect(discovery.candidates).toHaveLength(1); + expect(fs.realpathSync(discovery.candidates[0]?.source ?? "")).toBe(expectedRuntimeMainPath); + expect(fs.realpathSync(discovery.candidates[0]?.setupSource ?? "")).toBe( + expectedRuntimeSetupPath, + ); + expect(fs.realpathSync(manifestRegistry.plugins[0]?.setupSource ?? "")).toBe( + expectedRuntimeSetupPath, + ); + expect(manifestRegistry.plugins[0]?.startupDeferConfiguredChannelFullLoadUntilAfterListen).toBe( + true, + ); + }); + + it("removes stale runtime plugin directories that are no longer in dist", () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-stale-"); + const staleRuntimeDir = path.join(repoRoot, "dist-runtime", "extensions", "stale"); + fs.mkdirSync(staleRuntimeDir, { recursive: true }); + fs.writeFileSync(path.join(staleRuntimeDir, "index.js"), "stale\n", "utf8"); + fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true }); + + stageBundledPluginRuntime({ repoRoot }); + + expect(fs.existsSync(staleRuntimeDir)).toBe(false); + }); + + it("removes dist-runtime when the built bundled plugin tree is absent", () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-missing-"); + const runtimeRoot = path.join(repoRoot, "dist-runtime", "extensions", "diffs"); + fs.mkdirSync(runtimeRoot, { recursive: true }); + + stageBundledPluginRuntime({ repoRoot }); + + expect(fs.existsSync(path.join(repoRoot, "dist-runtime"))).toBe(false); + }); +}); diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 20e68f0ca66..80c41858733 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -170,4 +170,22 @@ describe("resolvePluginTools optional tools", () => { }), ); }); + + it("forwards gateway subagent binding to plugin runtime options", () => { + setOptionalDemoRegistry(); + + resolvePluginTools({ + context: createContext() as never, + allowGatewaySubagentBinding: true, + toolAllowlist: ["optional_tool"], + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }), + ); + }); }); diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index ebf96ec6a4c..9a1142a8306 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -47,6 +47,7 @@ export function resolvePluginTools(params: { existingToolNames?: Set; toolAllowlist?: string[]; suppressNameConflicts?: boolean; + allowGatewaySubagentBinding?: boolean; env?: NodeJS.ProcessEnv; }): AnyAgentTool[] { // Fast path: when plugins are effectively disabled, avoid discovery/jiti entirely. @@ -61,6 +62,11 @@ export function resolvePluginTools(params: { const registry = loadOpenClawPlugins({ config: effectiveConfig, workspaceDir: params.context.workspaceDir, + runtimeOptions: params.allowGatewaySubagentBinding + ? { + allowGatewaySubagentBinding: true, + } + : undefined, env, logger: createPluginLoaderLogger(log), }); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index b9b6e801214..2a2e2b9fd5f 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -27,6 +27,14 @@ import type { HookEntry } from "../hooks/types.js"; import type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js"; import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; +import type { + SpeechProviderConfiguredContext, + SpeechProviderId, + SpeechSynthesisRequest, + SpeechSynthesisResult, + SpeechTelephonySynthesisRequest, + SpeechTelephonySynthesisResult, +} from "../tts/provider-types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -849,6 +857,27 @@ export type WebSearchProviderPlugin = { createTool: (ctx: WebSearchProviderContext) => WebSearchProviderToolDefinition | null; }; +export type PluginWebSearchProviderEntry = WebSearchProviderPlugin & { + pluginId: string; +}; + +export type SpeechProviderPlugin = { + id: SpeechProviderId; + label: string; + aliases?: string[]; + models?: readonly string[]; + voices?: readonly string[]; + isConfigured: (ctx: SpeechProviderConfiguredContext) => boolean; + synthesize: (req: SpeechSynthesisRequest) => Promise; + synthesizeTelephony?: ( + req: SpeechTelephonySynthesisRequest, + ) => Promise; +}; + +export type PluginSpeechProviderEntry = SpeechProviderPlugin & { + pluginId: string; +}; + export type OpenClawPluginGatewayMethod = { method: string; handler: GatewayRequestHandler; @@ -1207,6 +1236,7 @@ export type OpenClawPluginApi = { registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerService: (service: OpenClawPluginService) => void; registerProvider: (provider: ProviderPlugin) => void; + registerSpeechProvider: (provider: SpeechProviderPlugin) => void; registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void; registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void; /** 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/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 4f52350f8fc..588c1ca7db6 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -26,6 +26,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl enabled: true, })), providers: [], + speechProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/test-utils/plugin-registration.ts b/src/test-utils/plugin-registration.ts index e17e4a2520d..6231dedf17b 100644 --- a/src/test-utils/plugin-registration.ts +++ b/src/test-utils/plugin-registration.ts @@ -2,29 +2,36 @@ import type { AnyAgentTool, OpenClawPluginApi, ProviderPlugin, + SpeechProviderPlugin, WebSearchProviderPlugin, } from "../plugins/types.js"; export type CapturedPluginRegistration = { api: OpenClawPluginApi; providers: ProviderPlugin[]; + speechProviders: SpeechProviderPlugin[]; webSearchProviders: WebSearchProviderPlugin[]; tools: AnyAgentTool[]; }; export function createCapturedPluginRegistration(): CapturedPluginRegistration { const providers: ProviderPlugin[] = []; + const speechProviders: SpeechProviderPlugin[] = []; const webSearchProviders: WebSearchProviderPlugin[] = []; const tools: AnyAgentTool[] = []; return { providers, + speechProviders, webSearchProviders, tools, api: { registerProvider(provider: ProviderPlugin) { providers.push(provider); }, + registerSpeechProvider(provider: SpeechProviderPlugin) { + speechProviders.push(provider); + }, registerWebSearchProvider(provider: WebSearchProviderPlugin) { webSearchProviders.push(provider); }, diff --git a/src/tts/provider-registry.ts b/src/tts/provider-registry.ts new file mode 100644 index 00000000000..ee60764aa4d --- /dev/null +++ b/src/tts/provider-registry.ts @@ -0,0 +1,84 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { getActivePluginRegistry } from "../plugins/runtime.js"; +import type { SpeechProviderPlugin } from "../plugins/types.js"; +import type { SpeechProviderId } from "./provider-types.js"; +import { buildElevenLabsSpeechProvider } from "./providers/elevenlabs.js"; +import { buildMicrosoftSpeechProvider } from "./providers/microsoft.js"; +import { buildOpenAISpeechProvider } from "./providers/openai.js"; + +const BUILTIN_SPEECH_PROVIDERS: readonly SpeechProviderPlugin[] = [ + buildOpenAISpeechProvider(), + buildElevenLabsSpeechProvider(), + buildMicrosoftSpeechProvider(), +]; + +function trimToUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim().toLowerCase(); + return trimmed ? trimmed : undefined; +} + +export function normalizeSpeechProviderId( + providerId: string | undefined, +): SpeechProviderId | undefined { + const normalized = trimToUndefined(providerId); + if (!normalized) { + return undefined; + } + return normalized === "edge" ? "microsoft" : normalized; +} + +function resolveSpeechProviderPluginEntries(cfg?: OpenClawConfig): SpeechProviderPlugin[] { + const active = getActivePluginRegistry(); + const registry = + (active?.speechProviders?.length ?? 0) > 0 || !cfg + ? active + : loadOpenClawPlugins({ config: cfg }); + return registry?.speechProviders?.map((entry) => entry.provider) ?? []; +} + +function buildProviderMaps(cfg?: OpenClawConfig): { + canonical: Map; + aliases: Map; +} { + const canonical = new Map(); + const aliases = new Map(); + const register = (provider: SpeechProviderPlugin) => { + const id = normalizeSpeechProviderId(provider.id); + if (!id) { + return; + } + canonical.set(id, provider); + aliases.set(id, provider); + for (const alias of provider.aliases ?? []) { + const normalizedAlias = normalizeSpeechProviderId(alias); + if (normalizedAlias) { + aliases.set(normalizedAlias, provider); + } + } + }; + + for (const provider of BUILTIN_SPEECH_PROVIDERS) { + register(provider); + } + for (const provider of resolveSpeechProviderPluginEntries(cfg)) { + register(provider); + } + + return { canonical, aliases }; +} + +export function listSpeechProviders(cfg?: OpenClawConfig): SpeechProviderPlugin[] { + return [...buildProviderMaps(cfg).canonical.values()]; +} + +export function getSpeechProvider( + providerId: string | undefined, + cfg?: OpenClawConfig, +): SpeechProviderPlugin | undefined { + const normalized = normalizeSpeechProviderId(providerId); + if (!normalized) { + return undefined; + } + return buildProviderMaps(cfg).aliases.get(normalized); +} diff --git a/src/tts/provider-types.ts b/src/tts/provider-types.ts new file mode 100644 index 00000000000..bfbeb38f02a --- /dev/null +++ b/src/tts/provider-types.ts @@ -0,0 +1,38 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { ResolvedTtsConfig, TtsDirectiveOverrides } from "./tts.js"; + +export type SpeechProviderId = string; + +export type SpeechSynthesisTarget = "audio-file" | "voice-note"; + +export type SpeechProviderConfiguredContext = { + cfg?: OpenClawConfig; + config: ResolvedTtsConfig; +}; + +export type SpeechSynthesisRequest = { + text: string; + cfg: OpenClawConfig; + config: ResolvedTtsConfig; + target: SpeechSynthesisTarget; + overrides?: TtsDirectiveOverrides; +}; + +export type SpeechSynthesisResult = { + audioBuffer: Buffer; + outputFormat: string; + fileExtension: string; + voiceCompatible: boolean; +}; + +export type SpeechTelephonySynthesisRequest = { + text: string; + cfg: OpenClawConfig; + config: ResolvedTtsConfig; +}; + +export type SpeechTelephonySynthesisResult = { + audioBuffer: Buffer; + outputFormat: string; + sampleRate: number; +}; diff --git a/src/tts/providers/elevenlabs.ts b/src/tts/providers/elevenlabs.ts new file mode 100644 index 00000000000..2b6df133edc --- /dev/null +++ b/src/tts/providers/elevenlabs.ts @@ -0,0 +1,73 @@ +import type { SpeechProviderPlugin } from "../../plugins/types.js"; +import { elevenLabsTTS } from "../tts-core.js"; + +const ELEVENLABS_TTS_MODELS = [ + "eleven_multilingual_v2", + "eleven_turbo_v2_5", + "eleven_monolingual_v1", +] as const; + +export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin { + return { + id: "elevenlabs", + label: "ElevenLabs", + models: ELEVENLABS_TTS_MODELS, + isConfigured: ({ config }) => + Boolean(config.elevenlabs.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY), + synthesize: async (req) => { + const apiKey = + req.config.elevenlabs.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY; + if (!apiKey) { + throw new Error("ElevenLabs API key missing"); + } + const outputFormat = req.target === "voice-note" ? "opus_48000_64" : "mp3_44100_128"; + const audioBuffer = await elevenLabsTTS({ + text: req.text, + apiKey, + baseUrl: req.config.elevenlabs.baseUrl, + voiceId: req.overrides?.elevenlabs?.voiceId ?? req.config.elevenlabs.voiceId, + modelId: req.overrides?.elevenlabs?.modelId ?? req.config.elevenlabs.modelId, + outputFormat, + seed: req.overrides?.elevenlabs?.seed ?? req.config.elevenlabs.seed, + applyTextNormalization: + req.overrides?.elevenlabs?.applyTextNormalization ?? + req.config.elevenlabs.applyTextNormalization, + languageCode: req.overrides?.elevenlabs?.languageCode ?? req.config.elevenlabs.languageCode, + voiceSettings: { + ...req.config.elevenlabs.voiceSettings, + ...req.overrides?.elevenlabs?.voiceSettings, + }, + timeoutMs: req.config.timeoutMs, + }); + return { + audioBuffer, + outputFormat, + fileExtension: req.target === "voice-note" ? ".opus" : ".mp3", + voiceCompatible: req.target === "voice-note", + }; + }, + synthesizeTelephony: async (req) => { + const apiKey = + req.config.elevenlabs.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY; + if (!apiKey) { + throw new Error("ElevenLabs API key missing"); + } + const outputFormat = "pcm_22050"; + const sampleRate = 22_050; + const audioBuffer = await elevenLabsTTS({ + text: req.text, + apiKey, + baseUrl: req.config.elevenlabs.baseUrl, + voiceId: req.config.elevenlabs.voiceId, + modelId: req.config.elevenlabs.modelId, + outputFormat, + seed: req.config.elevenlabs.seed, + applyTextNormalization: req.config.elevenlabs.applyTextNormalization, + languageCode: req.config.elevenlabs.languageCode, + voiceSettings: req.config.elevenlabs.voiceSettings, + timeoutMs: req.config.timeoutMs, + }); + return { audioBuffer, outputFormat, sampleRate }; + }, + }; +} diff --git a/src/tts/providers/microsoft.ts b/src/tts/providers/microsoft.ts new file mode 100644 index 00000000000..ee31e35a204 --- /dev/null +++ b/src/tts/providers/microsoft.ts @@ -0,0 +1,60 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; +import { isVoiceCompatibleAudio } from "../../media/audio.js"; +import type { SpeechProviderPlugin } from "../../plugins/types.js"; +import { edgeTTS, inferEdgeExtension } from "../tts-core.js"; + +const DEFAULT_EDGE_OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3"; + +export function buildMicrosoftSpeechProvider(): SpeechProviderPlugin { + return { + id: "microsoft", + label: "Microsoft", + aliases: ["edge"], + isConfigured: ({ config }) => config.edge.enabled, + synthesize: async (req) => { + const tempRoot = resolvePreferredOpenClawTmpDir(); + mkdirSync(tempRoot, { recursive: true, mode: 0o700 }); + const tempDir = mkdtempSync(path.join(tempRoot, "tts-microsoft-")); + let outputFormat = req.config.edge.outputFormat; + const fallbackOutputFormat = + outputFormat !== DEFAULT_EDGE_OUTPUT_FORMAT ? DEFAULT_EDGE_OUTPUT_FORMAT : undefined; + + try { + const runEdge = async (format: string) => { + const fileExtension = inferEdgeExtension(format); + const outputPath = path.join(tempDir, `speech${fileExtension}`); + await edgeTTS({ + text: req.text, + outputPath, + config: { + ...req.config.edge, + outputFormat: format, + }, + timeoutMs: req.config.timeoutMs, + }); + const audioBuffer = readFileSync(outputPath); + return { + audioBuffer, + outputFormat: format, + fileExtension, + voiceCompatible: isVoiceCompatibleAudio({ fileName: outputPath }), + }; + }; + + try { + return await runEdge(outputFormat); + } catch (err) { + if (!fallbackOutputFormat || fallbackOutputFormat === outputFormat) { + throw err; + } + outputFormat = fallbackOutputFormat; + return await runEdge(outputFormat); + } + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }, + }; +} diff --git a/src/tts/providers/openai.ts b/src/tts/providers/openai.ts new file mode 100644 index 00000000000..bf52c1644a9 --- /dev/null +++ b/src/tts/providers/openai.ts @@ -0,0 +1,56 @@ +import type { SpeechProviderPlugin } from "../../plugins/types.js"; +import { OPENAI_TTS_MODELS, OPENAI_TTS_VOICES, openaiTTS } from "../tts-core.js"; + +export function buildOpenAISpeechProvider(): SpeechProviderPlugin { + return { + id: "openai", + label: "OpenAI", + models: OPENAI_TTS_MODELS, + voices: OPENAI_TTS_VOICES, + isConfigured: ({ config }) => Boolean(config.openai.apiKey || process.env.OPENAI_API_KEY), + synthesize: async (req) => { + const apiKey = req.config.openai.apiKey || process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error("OpenAI API key missing"); + } + const responseFormat = req.target === "voice-note" ? "opus" : "mp3"; + const audioBuffer = await openaiTTS({ + text: req.text, + apiKey, + baseUrl: req.config.openai.baseUrl, + model: req.overrides?.openai?.model ?? req.config.openai.model, + voice: req.overrides?.openai?.voice ?? req.config.openai.voice, + speed: req.config.openai.speed, + instructions: req.config.openai.instructions, + responseFormat, + timeoutMs: req.config.timeoutMs, + }); + return { + audioBuffer, + outputFormat: responseFormat, + fileExtension: responseFormat === "opus" ? ".opus" : ".mp3", + voiceCompatible: req.target === "voice-note", + }; + }, + synthesizeTelephony: async (req) => { + const apiKey = req.config.openai.apiKey || process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error("OpenAI API key missing"); + } + const outputFormat = "pcm"; + const sampleRate = 24_000; + const audioBuffer = await openaiTTS({ + text: req.text, + apiKey, + baseUrl: req.config.openai.baseUrl, + model: req.config.openai.model, + voice: req.config.openai.voice, + speed: req.config.openai.speed, + instructions: req.config.openai.instructions, + responseFormat: outputFormat, + timeoutMs: req.config.timeoutMs, + }); + return { audioBuffer, outputFormat, sampleRate }; + }, + }; +} diff --git a/src/tts/tts-core.ts b/src/tts/tts-core.ts index 5d3000d7ad3..7bdc8f56288 100644 --- a/src/tts/tts-core.ts +++ b/src/tts/tts-core.ts @@ -156,10 +156,13 @@ export function parseTtsDirectives( if (!policy.allowProvider) { break; } - if (rawValue === "openai" || rawValue === "elevenlabs" || rawValue === "edge") { - overrides.provider = rawValue; - } else { - warnings.push(`unsupported provider "${rawValue}"`); + { + const providerId = rawValue.trim().toLowerCase(); + if (providerId) { + overrides.provider = providerId; + } else { + warnings.push("invalid provider id"); + } } break; case "voice": diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 8b232ed034d..16b91b6f330 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -311,7 +311,7 @@ describe("tts", () => { expect(result.overrides.elevenlabs?.voiceSettings?.speed).toBe(1.1); }); - it("accepts edge as provider override", () => { + it("accepts edge as a legacy microsoft provider override", () => { const policy = resolveModelOverridePolicy({ enabled: true, allowProvider: true }); const input = "Hello [[tts:provider=edge]] world"; const result = parseTtsDirectives(input, policy); @@ -524,8 +524,8 @@ describe("tts", () => { ELEVENLABS_API_KEY: undefined, XI_API_KEY: undefined, }, - prefsPath: "/tmp/tts-prefs-edge.json", - expected: "edge", + prefsPath: "/tmp/tts-prefs-microsoft.json", + expected: "microsoft", }, ] as const; @@ -539,6 +539,25 @@ describe("tts", () => { }); }); + describe("resolveTtsConfig provider normalization", () => { + it("normalizes legacy edge provider ids to microsoft", () => { + const config = resolveTtsConfig({ + agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, + messages: { + tts: { + provider: "edge", + edge: { + enabled: true, + }, + }, + }, + }); + + expect(config.provider).toBe("microsoft"); + expect(getTtsProvider(config, "/tmp/tts-prefs-normalized.json")).toBe("microsoft"); + }); + }); + describe("resolveTtsConfig – openai.baseUrl", () => { const baseCfg: OpenClawConfig = { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 403efc10543..44cb57fd6e8 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -5,7 +5,6 @@ import { readFileSync, writeFileSync, mkdtempSync, - rmSync, renameSync, unlinkSync, } from "node:fs"; @@ -25,20 +24,20 @@ import type { import { logVerbose } from "../globals.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { stripMarkdown } from "../line/markdown-to-line.js"; -import { isVoiceCompatibleAudio } from "../media/audio.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { + getSpeechProvider, + listSpeechProviders, + normalizeSpeechProviderId, +} from "./provider-registry.js"; import { DEFAULT_OPENAI_BASE_URL, - edgeTTS, - elevenLabsTTS, - inferEdgeExtension, isValidOpenAIModel, isValidOpenAIVoice, isValidVoiceId, OPENAI_TTS_MODELS, OPENAI_TTS_VOICES, resolveOpenAITtsInstructions, - openaiTTS, parseTtsDirectives, scheduleCleanup, summarizeText, @@ -83,11 +82,6 @@ const DEFAULT_OUTPUT = { voiceCompatible: false, }; -const TELEPHONY_OUTPUT = { - openai: { format: "pcm" as const, sampleRate: 24000 }, - elevenlabs: { format: "pcm_22050", sampleRate: 22050 }, -}; - const TTS_AUTO_MODES = new Set(["off", "always", "inbound", "tagged"]); export type ResolvedTtsConfig = { @@ -261,12 +255,13 @@ function resolveModelOverridePolicy( export function resolveTtsConfig(cfg: OpenClawConfig): ResolvedTtsConfig { const raw: TtsConfig = cfg.messages?.tts ?? {}; const providerSource = raw.provider ? "config" : "default"; - const edgeOutputFormat = raw.edge?.outputFormat?.trim(); + const rawMicrosoft = { ...raw.edge, ...raw.microsoft }; + const edgeOutputFormat = rawMicrosoft.outputFormat?.trim(); const auto = normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off"); return { auto, mode: raw.mode ?? "final", - provider: raw.provider ?? "edge", + provider: normalizeSpeechProviderId(raw.provider) ?? "microsoft", providerSource, summaryModel: raw.summaryModel?.trim() || undefined, modelOverrides: resolveModelOverridePolicy(raw.modelOverrides), @@ -311,17 +306,17 @@ export function resolveTtsConfig(cfg: OpenClawConfig): ResolvedTtsConfig { instructions: raw.openai?.instructions?.trim() || undefined, }, edge: { - enabled: raw.edge?.enabled ?? true, - voice: raw.edge?.voice?.trim() || DEFAULT_EDGE_VOICE, - lang: raw.edge?.lang?.trim() || DEFAULT_EDGE_LANG, + enabled: rawMicrosoft.enabled ?? true, + voice: rawMicrosoft.voice?.trim() || DEFAULT_EDGE_VOICE, + lang: rawMicrosoft.lang?.trim() || DEFAULT_EDGE_LANG, outputFormat: edgeOutputFormat || DEFAULT_EDGE_OUTPUT_FORMAT, outputFormatConfigured: Boolean(edgeOutputFormat), - pitch: raw.edge?.pitch?.trim() || undefined, - rate: raw.edge?.rate?.trim() || undefined, - volume: raw.edge?.volume?.trim() || undefined, - saveSubtitles: raw.edge?.saveSubtitles ?? false, - proxy: raw.edge?.proxy?.trim() || undefined, - timeoutMs: raw.edge?.timeoutMs, + pitch: rawMicrosoft.pitch?.trim() || undefined, + rate: rawMicrosoft.rate?.trim() || undefined, + volume: rawMicrosoft.volume?.trim() || undefined, + saveSubtitles: rawMicrosoft.saveSubtitles ?? false, + proxy: rawMicrosoft.proxy?.trim() || undefined, + timeoutMs: rawMicrosoft.timeoutMs, }, prefsPath: raw.prefsPath, maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH, @@ -448,11 +443,12 @@ export function setTtsEnabled(prefsPath: string, enabled: boolean): void { export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider { const prefs = readPrefs(prefsPath); - if (prefs.tts?.provider) { - return prefs.tts.provider; + const prefsProvider = normalizeSpeechProviderId(prefs.tts?.provider); + if (prefsProvider) { + return prefsProvider; } if (config.providerSource === "config") { - return config.provider; + return normalizeSpeechProviderId(config.provider) ?? config.provider; } if (resolveTtsApiKey(config, "openai")) { @@ -461,12 +457,12 @@ export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): Tt if (resolveTtsApiKey(config, "elevenlabs")) { return "elevenlabs"; } - return "edge"; + return "microsoft"; } export function setTtsProvider(prefsPath: string, provider: TtsProvider): void { updatePrefs(prefsPath, (prefs) => { - prefs.tts = { ...prefs.tts, provider }; + prefs.tts = { ...prefs.tts, provider: normalizeSpeechProviderId(provider) ?? provider }; }); } @@ -522,26 +518,42 @@ export function resolveTtsApiKey( config: ResolvedTtsConfig, provider: TtsProvider, ): string | undefined { - if (provider === "elevenlabs") { + const normalizedProvider = normalizeSpeechProviderId(provider); + if (normalizedProvider === "elevenlabs") { return config.elevenlabs.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY; } - if (provider === "openai") { + if (normalizedProvider === "openai") { return config.openai.apiKey || process.env.OPENAI_API_KEY; } return undefined; } -export const TTS_PROVIDERS = ["openai", "elevenlabs", "edge"] as const; +export const TTS_PROVIDERS = ["openai", "elevenlabs", "microsoft"] as const; -export function resolveTtsProviderOrder(primary: TtsProvider): TtsProvider[] { - return [primary, ...TTS_PROVIDERS.filter((provider) => provider !== primary)]; +export function resolveTtsProviderOrder(primary: TtsProvider, cfg?: OpenClawConfig): TtsProvider[] { + const normalizedPrimary = normalizeSpeechProviderId(primary) ?? primary; + const ordered = new Set([normalizedPrimary]); + for (const provider of TTS_PROVIDERS) { + if (provider !== normalizedPrimary) { + ordered.add(provider); + } + } + for (const provider of listSpeechProviders(cfg)) { + const normalized = normalizeSpeechProviderId(provider.id) ?? provider.id; + if (normalized !== normalizedPrimary) { + ordered.add(normalized); + } + } + return [...ordered]; } -export function isTtsProviderConfigured(config: ResolvedTtsConfig, provider: TtsProvider): boolean { - if (provider === "edge") { - return config.edge.enabled; - } - return Boolean(resolveTtsApiKey(config, provider)); +export function isTtsProviderConfigured( + config: ResolvedTtsConfig, + provider: TtsProvider, + cfg?: OpenClawConfig, +): boolean { + const resolvedProvider = getSpeechProvider(provider, cfg); + return resolvedProvider?.isConfigured({ cfg, config }) ?? false; } function formatTtsProviderError(provider: TtsProvider, err: unknown): string { @@ -581,10 +593,10 @@ function resolveTtsRequestSetup(params: { } const userProvider = getTtsProvider(config, prefsPath); - const provider = params.providerOverride ?? userProvider; + const provider = normalizeSpeechProviderId(params.providerOverride) ?? userProvider; return { config, - providers: resolveTtsProviderOrder(provider), + providers: resolveTtsProviderOrder(provider, params.cfg), }; } @@ -607,136 +619,36 @@ export async function textToSpeech(params: { const { config, providers } = setup; const channelId = resolveChannelId(params.channel); - const output = resolveOutputFormat(channelId); + const target = channelId && VOICE_BUBBLE_CHANNELS.has(channelId) ? "voice-note" : "audio-file"; const errors: string[] = []; for (const provider of providers) { const providerStart = Date.now(); try { - if (provider === "edge") { - if (!config.edge.enabled) { - errors.push("edge: disabled"); - continue; - } - - const tempRoot = resolvePreferredOpenClawTmpDir(); - mkdirSync(tempRoot, { recursive: true, mode: 0o700 }); - const tempDir = mkdtempSync(path.join(tempRoot, "tts-")); - let edgeOutputFormat = resolveEdgeOutputFormat(config); - const fallbackEdgeOutputFormat = - edgeOutputFormat !== DEFAULT_EDGE_OUTPUT_FORMAT ? DEFAULT_EDGE_OUTPUT_FORMAT : undefined; - - const attemptEdgeTts = async (outputFormat: string) => { - const extension = inferEdgeExtension(outputFormat); - const audioPath = path.join(tempDir, `voice-${Date.now()}${extension}`); - await edgeTTS({ - text: params.text, - outputPath: audioPath, - config: { - ...config.edge, - outputFormat, - }, - timeoutMs: config.timeoutMs, - }); - return { audioPath, outputFormat }; - }; - - let edgeResult: { audioPath: string; outputFormat: string }; - try { - edgeResult = await attemptEdgeTts(edgeOutputFormat); - } catch (err) { - if (fallbackEdgeOutputFormat && fallbackEdgeOutputFormat !== edgeOutputFormat) { - logVerbose( - `TTS: Edge output ${edgeOutputFormat} failed; retrying with ${fallbackEdgeOutputFormat}.`, - ); - edgeOutputFormat = fallbackEdgeOutputFormat; - try { - edgeResult = await attemptEdgeTts(edgeOutputFormat); - } catch (fallbackErr) { - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - // ignore cleanup errors - } - throw fallbackErr; - } - } else { - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - // ignore cleanup errors - } - throw err; - } - } - - scheduleCleanup(tempDir); - const voiceCompatible = isVoiceCompatibleAudio({ fileName: edgeResult.audioPath }); - - return { - success: true, - audioPath: edgeResult.audioPath, - latencyMs: Date.now() - providerStart, - provider, - outputFormat: edgeResult.outputFormat, - voiceCompatible, - }; - } - - const apiKey = resolveTtsApiKey(config, provider); - if (!apiKey) { - errors.push(`${provider}: no API key`); + const resolvedProvider = getSpeechProvider(provider, params.cfg); + if (!resolvedProvider) { + errors.push(`${provider}: no provider registered`); continue; } - - let audioBuffer: Buffer; - if (provider === "elevenlabs") { - const voiceIdOverride = params.overrides?.elevenlabs?.voiceId; - const modelIdOverride = params.overrides?.elevenlabs?.modelId; - const voiceSettings = { - ...config.elevenlabs.voiceSettings, - ...params.overrides?.elevenlabs?.voiceSettings, - }; - const seedOverride = params.overrides?.elevenlabs?.seed; - const normalizationOverride = params.overrides?.elevenlabs?.applyTextNormalization; - const languageOverride = params.overrides?.elevenlabs?.languageCode; - audioBuffer = await elevenLabsTTS({ - text: params.text, - apiKey, - baseUrl: config.elevenlabs.baseUrl, - voiceId: voiceIdOverride ?? config.elevenlabs.voiceId, - modelId: modelIdOverride ?? config.elevenlabs.modelId, - outputFormat: output.elevenlabs, - seed: seedOverride ?? config.elevenlabs.seed, - applyTextNormalization: normalizationOverride ?? config.elevenlabs.applyTextNormalization, - languageCode: languageOverride ?? config.elevenlabs.languageCode, - voiceSettings, - timeoutMs: config.timeoutMs, - }); - } else { - const openaiModelOverride = params.overrides?.openai?.model; - const openaiVoiceOverride = params.overrides?.openai?.voice; - audioBuffer = await openaiTTS({ - text: params.text, - apiKey, - baseUrl: config.openai.baseUrl, - model: openaiModelOverride ?? config.openai.model, - voice: openaiVoiceOverride ?? config.openai.voice, - speed: config.openai.speed, - instructions: config.openai.instructions, - responseFormat: output.openai, - timeoutMs: config.timeoutMs, - }); + if (!resolvedProvider.isConfigured({ cfg: params.cfg, config })) { + errors.push(`${provider}: not configured`); + continue; } - + const synthesis = await resolvedProvider.synthesize({ + text: params.text, + cfg: params.cfg, + config, + target, + overrides: params.overrides, + }); const latencyMs = Date.now() - providerStart; const tempRoot = resolvePreferredOpenClawTmpDir(); mkdirSync(tempRoot, { recursive: true, mode: 0o700 }); const tempDir = mkdtempSync(path.join(tempRoot, "tts-")); - const audioPath = path.join(tempDir, `voice-${Date.now()}${output.extension}`); - writeFileSync(audioPath, audioBuffer); + const audioPath = path.join(tempDir, `voice-${Date.now()}${synthesis.fileExtension}`); + writeFileSync(audioPath, synthesis.audioBuffer); scheduleCleanup(tempDir); return { @@ -744,8 +656,8 @@ export async function textToSpeech(params: { audioPath, latencyMs, provider, - outputFormat: provider === "openai" ? output.openai : output.elevenlabs, - voiceCompatible: output.voiceCompatible, + outputFormat: synthesis.outputFormat, + voiceCompatible: synthesis.voiceCompatible, }; } catch (err) { errors.push(formatTtsProviderError(provider, err)); @@ -776,63 +688,32 @@ export async function textToSpeechTelephony(params: { for (const provider of providers) { const providerStart = Date.now(); try { - if (provider === "edge") { - errors.push("edge: unsupported for telephony"); + const resolvedProvider = getSpeechProvider(provider, params.cfg); + if (!resolvedProvider) { + errors.push(`${provider}: no provider registered`); continue; } - - const apiKey = resolveTtsApiKey(config, provider); - if (!apiKey) { - errors.push(`${provider}: no API key`); + if (!resolvedProvider.isConfigured({ cfg: params.cfg, config })) { + errors.push(`${provider}: not configured`); continue; } - - if (provider === "elevenlabs") { - const output = TELEPHONY_OUTPUT.elevenlabs; - const audioBuffer = await elevenLabsTTS({ - text: params.text, - apiKey, - baseUrl: config.elevenlabs.baseUrl, - voiceId: config.elevenlabs.voiceId, - modelId: config.elevenlabs.modelId, - outputFormat: output.format, - seed: config.elevenlabs.seed, - applyTextNormalization: config.elevenlabs.applyTextNormalization, - languageCode: config.elevenlabs.languageCode, - voiceSettings: config.elevenlabs.voiceSettings, - timeoutMs: config.timeoutMs, - }); - - return { - success: true, - audioBuffer, - latencyMs: Date.now() - providerStart, - provider, - outputFormat: output.format, - sampleRate: output.sampleRate, - }; + if (!resolvedProvider.synthesizeTelephony) { + errors.push(`${provider}: unsupported for telephony`); + continue; } - - const output = TELEPHONY_OUTPUT.openai; - const audioBuffer = await openaiTTS({ + const synthesis = await resolvedProvider.synthesizeTelephony({ text: params.text, - apiKey, - baseUrl: config.openai.baseUrl, - model: config.openai.model, - voice: config.openai.voice, - speed: config.openai.speed, - instructions: config.openai.instructions, - responseFormat: output.format, - timeoutMs: config.timeoutMs, + cfg: params.cfg, + config, }); return { success: true, - audioBuffer, + audioBuffer: synthesis.audioBuffer, latencyMs: Date.now() - providerStart, provider, - outputFormat: output.format, - sampleRate: output.sampleRate, + outputFormat: synthesis.outputFormat, + sampleRate: synthesis.sampleRate, }; } catch (err) { errors.push(formatTtsProviderError(provider, err)); diff --git a/src/types/extension-api.d.ts b/src/types/extension-api.d.ts deleted file mode 100644 index ca711425cab..00000000000 --- a/src/types/extension-api.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module "../../../dist/extensionAPI.js" { - export const runEmbeddedPiAgent: (params: Record) => Promise; -} diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts new file mode 100644 index 00000000000..ab9400da5db --- /dev/null +++ b/test/openclaw-launcher.e2e.test.ts @@ -0,0 +1,58 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +async function makeLauncherFixture(fixtureRoots: string[]): Promise { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-launcher-")); + fixtureRoots.push(fixtureRoot); + await fs.copyFile( + path.resolve(process.cwd(), "openclaw.mjs"), + path.join(fixtureRoot, "openclaw.mjs"), + ); + await fs.mkdir(path.join(fixtureRoot, "dist"), { recursive: true }); + return fixtureRoot; +} + +describe("openclaw launcher", () => { + const fixtureRoots: string[] = []; + + afterEach(async () => { + await Promise.all( + fixtureRoots.splice(0).map(async (fixtureRoot) => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + }), + ); + }); + + it("surfaces transitive entry import failures instead of masking them as missing dist", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + 'import "missing-openclaw-launcher-dep";\nexport {};\n', + "utf8", + ); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { + cwd: fixtureRoot, + encoding: "utf8", + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("missing-openclaw-launcher-dep"); + expect(result.stderr).not.toContain("missing dist/entry.(m)js"); + }); + + it("keeps the friendly launcher error for a truly missing entry build output", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { + cwd: fixtureRoot, + encoding: "utf8", + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("missing dist/entry.(m)js"); + }); +}); diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 1ab4a68deb8..8919130c19a 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -1,7 +1,11 @@ import { execFileSync } from "node:child_process"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveExtensionTestPlan } from "../../scripts/test-extension.mjs"; +import { + detectChangedExtensionIds, + listAvailableExtensionIds, + resolveExtensionTestPlan, +} from "../../scripts/test-extension.mjs"; const scriptPath = path.join(process.cwd(), "scripts", "test-extension.mjs"); @@ -47,4 +51,25 @@ describe("scripts/test-extension.mjs", () => { 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"]); + }); + + it("lists available extension ids", () => { + const extensionIds = listAvailableExtensionIds(); + + expect(extensionIds).toContain("slack"); + expect(extensionIds).toContain("firecrawl"); + expect(extensionIds).toEqual( + [...extensionIds].toSorted((left, right) => left.localeCompare(right)), + ); + }); }); diff --git a/tsconfig.json b/tsconfig.json index e2f9e4ff97e..bc6439e921f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,6 @@ "target": "es2023", "useDefineForClassFields": false, "paths": { - "openclaw/extension-api": ["./src/extensionAPI.ts"], "openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], "openclaw/plugin-sdk/account-id": ["./src/plugin-sdk/account-id.ts"] diff --git a/tsdown.config.ts b/tsdown.config.ts index 60ae26d04e0..966e12afc10 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,13 +1,30 @@ import fs from "node:fs"; import path from "node:path"; -import { defineConfig } from "tsdown"; +import { defineConfig, type UserConfig } from "tsdown"; import { buildPluginSdkEntrySources } from "./scripts/lib/plugin-sdk-entries.mjs"; +type InputOptionsFactory = Extract, Function>; +type InputOptionsArg = InputOptionsFactory extends ( + options: infer Options, + format: infer _Format, + context: infer _Context, +) => infer _Return + ? Options + : never; +type InputOptionsReturn = InputOptionsFactory extends ( + options: infer _Options, + format: infer _Format, + context: infer _Context, +) => infer Return + ? Return + : never; +type OnLogFunction = InputOptionsArg extends { onLog?: infer OnLog } ? NonNullable : never; + const env = { NODE_ENV: "production", }; -function buildInputOptions(options: { onLog?: unknown; [key: string]: unknown }) { +function buildInputOptions(options: InputOptionsArg): InputOptionsReturn { if (process.env.OPENCLAW_BUILD_VERBOSE === "1") { return undefined; } @@ -32,11 +49,8 @@ function buildInputOptions(options: { onLog?: unknown; [key: string]: unknown }) return { ...options, - onLog( - level: string, - log: { code?: string; message?: string; id?: string; importer?: string }, - defaultHandler: (level: string, log: { code?: string }) => void, - ) { + onLog(...args: Parameters) { + const [level, log, defaultHandler] = args; if (isSuppressedLog(log)) { return; } @@ -49,7 +63,7 @@ function buildInputOptions(options: { onLog?: unknown; [key: string]: unknown }) }; } -function nodeBuildConfig(config: Record) { +function nodeBuildConfig(config: UserConfig): UserConfig { return { ...config, env, @@ -112,54 +126,79 @@ function listBundledPluginBuildEntries(): Record { const bundledPluginBuildEntries = listBundledPluginBuildEntries(); +function buildBundledHookEntries(): Record { + const hooksRoot = path.join(process.cwd(), "src", "hooks", "bundled"); + const entries: Record = {}; + + if (!fs.existsSync(hooksRoot)) { + return entries; + } + + for (const dirent of fs.readdirSync(hooksRoot, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + + const hookName = dirent.name; + const handlerPath = path.join(hooksRoot, hookName, "handler.ts"); + if (!fs.existsSync(handlerPath)) { + continue; + } + + entries[`bundled/${hookName}/handler`] = handlerPath; + } + + return entries; +} + +const bundledHookEntries = buildBundledHookEntries(); + +function buildCoreDistEntries(): Record { + return { + index: "src/index.ts", + entry: "src/entry.ts", + // Ensure this module is bundled as an entry so legacy CLI shims can resolve its exports. + "cli/daemon-cli": "src/cli/daemon-cli.ts", + "infra/warning-filter": "src/infra/warning-filter.ts", + // Keep sync lazy-runtime channel modules as concrete dist files. + "channels/plugins/agent-tools/whatsapp-login": + "src/channels/plugins/agent-tools/whatsapp-login.ts", + "channels/plugins/actions/discord": "src/channels/plugins/actions/discord.ts", + "channels/plugins/actions/signal": "src/channels/plugins/actions/signal.ts", + "channels/plugins/actions/telegram": "src/channels/plugins/actions/telegram.ts", + "telegram/audit": "extensions/telegram/src/audit.ts", + "telegram/token": "extensions/telegram/src/token.ts", + "line/accounts": "src/line/accounts.ts", + "line/send": "src/line/send.ts", + "line/template-messages": "src/line/template-messages.ts", + "plugins/runtime/index": "src/plugins/runtime/index.ts", + "llm-slug-generator": "src/hooks/llm-slug-generator.ts", + }; +} + +const coreDistEntries = buildCoreDistEntries(); + +function buildUnifiedDistEntries(): Record { + return { + ...coreDistEntries, + ...Object.fromEntries( + Object.entries(buildPluginSdkEntrySources()).map(([entry, source]) => [ + `plugin-sdk/${entry}`, + source, + ]), + ), + ...bundledPluginBuildEntries, + ...bundledHookEntries, + }; +} + export default defineConfig([ nodeBuildConfig({ - entry: "src/index.ts", - }), - nodeBuildConfig({ - entry: "src/entry.ts", - }), - nodeBuildConfig({ - // Ensure this module is bundled as an entry so legacy CLI shims can resolve its exports. - entry: "src/cli/daemon-cli.ts", - }), - nodeBuildConfig({ - entry: "src/infra/warning-filter.ts", - }), - nodeBuildConfig({ - // Keep sync lazy-runtime channel modules as concrete dist files. - entry: { - "channels/plugins/agent-tools/whatsapp-login": - "src/channels/plugins/agent-tools/whatsapp-login.ts", - "channels/plugins/actions/discord": "src/channels/plugins/actions/discord.ts", - "channels/plugins/actions/signal": "src/channels/plugins/actions/signal.ts", - "channels/plugins/actions/telegram": "src/channels/plugins/actions/telegram.ts", - "telegram/audit": "extensions/telegram/src/audit.ts", - "telegram/token": "extensions/telegram/src/token.ts", - "line/accounts": "src/line/accounts.ts", - "line/send": "src/line/send.ts", - "line/template-messages": "src/line/template-messages.ts", - }, - }), - nodeBuildConfig({ - // Bundle all plugin-sdk entries in a single build so the bundler can share - // common chunks instead of duplicating them per entry (~712MB heap saved). - entry: buildPluginSdkEntrySources(), - outDir: "dist/plugin-sdk", - }), - nodeBuildConfig({ - // Bundle bundled plugin entrypoints so built gateway startup can load JS - // directly from dist/extensions instead of transpiling extensions/*.ts via Jiti. - entry: bundledPluginBuildEntries, - outDir: "dist", + // Build core entrypoints, plugin-sdk subpaths, bundled plugin entrypoints, + // and bundled hooks in one graph so runtime singletons are emitted once. + entry: buildUnifiedDistEntries(), deps: { neverBundle: ["@lancedb/lancedb"], }, }), - nodeBuildConfig({ - entry: "src/extensionAPI.ts", - }), - nodeBuildConfig({ - entry: ["src/hooks/bundled/*/handler.ts", "src/hooks/llm-slug-generator.ts"], - }), ]); 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-chat.ts b/ui/src/ui/app-chat.ts index ec5f7300000..dc8eaf39be6 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -1,5 +1,5 @@ import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js"; -import { scheduleChatScroll } from "./app-scroll.ts"; +import { scheduleChatScroll, resetChatScroll } from "./app-scroll.ts"; import { setLastActiveSessionKey } from "./app-settings.ts"; import { resetToolStream } from "./app-tool-stream.ts"; import type { OpenClawApp } from "./app.ts"; @@ -121,6 +121,8 @@ async function sendChatMessageNow( }, ) { resetToolStream(host as unknown as Parameters[0]); + // Reset scroll state before sending to ensure auto-scroll works for the response + resetChatScroll(host as unknown as Parameters[0]); const runId = await sendChatMessage(host as unknown as OpenClawApp, message, opts?.attachments); const ok = Boolean(runId); if (!ok && opts?.previousDraft != null) { @@ -141,7 +143,8 @@ async function sendChatMessageNow( if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) { host.chatAttachments = opts.previousAttachments; } - scheduleChatScroll(host as unknown as Parameters[0]); + // Force scroll after sending to ensure viewport is at bottom for incoming stream + scheduleChatScroll(host as unknown as Parameters[0], true); if (ok && !host.chatRunId) { void flushChatQueue(host); } diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 28fb5271ecc..ae816a0bdb9 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -34,7 +34,7 @@ type LifecycleHost = { chatLoading: boolean; chatMessages: unknown[]; chatToolMessages: unknown[]; - chatStream: string; + chatStream: string | null; logsAutoFollow: boolean; logsAtBottom: boolean; logsEntries: unknown[]; @@ -99,9 +99,15 @@ export function handleUpdated(host: LifecycleHost, changed: Map[0], - forcedByTab || forcedByLoad || !host.chatHasAutoScrolled, + forcedByTab || forcedByLoad || streamJustStarted || !host.chatHasAutoScrolled, ); } if ( 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); } diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 68e4a3afe01..5e02b2649e2 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -2,6 +2,7 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; +import { i18n } from "../../i18n/index.ts"; import { getSafeLocalStorage } from "../../local-storage.ts"; import { renderChatSessionSelect } from "../app-render.helpers.ts"; import type { AppViewState } from "../app-view-state.ts"; @@ -9,6 +10,7 @@ import type { GatewayBrowserClient } from "../gateway.ts"; import type { ModelCatalogEntry } from "../types.ts"; import type { SessionsListResult } from "../types.ts"; import { renderChat, type ChatProps } from "./chat.ts"; +import { renderOverview, type OverviewProps } from "./overview.ts"; function createSessions(): SessionsListResult { return { @@ -195,6 +197,57 @@ function createProps(overrides: Partial = {}): ChatProps { }; } +function createOverviewProps(overrides: Partial = {}): OverviewProps { + return { + connected: false, + hello: null, + settings: { + gatewayUrl: "", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + locale: "en", + }, + password: "", + lastError: null, + lastErrorCode: null, + presenceCount: 0, + sessionsCount: null, + cronEnabled: null, + cronNext: null, + lastChannelsRefresh: null, + usageResult: null, + sessionsResult: null, + skillsReport: null, + cronJobs: [], + cronStatus: null, + attentionItems: [], + eventLog: [], + overviewLogLines: [], + showGatewayToken: false, + showGatewayPassword: false, + onSettingsChange: () => undefined, + onPasswordChange: () => undefined, + onSessionKeyChange: () => undefined, + onToggleGatewayTokenVisibility: () => undefined, + onToggleGatewayPasswordVisibility: () => undefined, + onConnect: () => undefined, + onRefresh: () => undefined, + onNavigate: () => undefined, + onRefreshLogs: () => undefined, + ...overrides, + }; +} + describe("chat view", () => { it("uses the assistant avatar URL for the welcome state when the identity avatar is only initials", () => { const container = document.createElement("div"); @@ -285,6 +338,41 @@ describe("chat view", () => { expect(groupedLogo?.getAttribute("src")).toBe("/openclaw/favicon.svg"); }); + it("keeps the persisted overview locale selected before i18n hydration finishes", async () => { + const container = document.createElement("div"); + const props = createOverviewProps({ + settings: { + ...createOverviewProps().settings, + locale: "zh-CN", + }, + }); + + try { + localStorage.clear(); + } catch { + /* noop */ + } + await i18n.setLocale("en"); + + render(renderOverview(props), container); + await Promise.resolve(); + + let select = container.querySelector("select"); + expect(i18n.getLocale()).toBe("en"); + expect(select?.value).toBe("zh-CN"); + expect(select?.selectedOptions[0]?.textContent?.trim()).toBe("简体中文 (Simplified Chinese)"); + + await i18n.setLocale("zh-CN"); + render(renderOverview(props), container); + await Promise.resolve(); + + select = container.querySelector("select"); + expect(select?.value).toBe("zh-CN"); + expect(select?.selectedOptions[0]?.textContent?.trim()).toBe("简体中文 (简体中文)"); + + await i18n.setLocale("en"); + }); + it("renders compacting indicator as a badge", () => { const container = document.createElement("div"); render( diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index d24aa92ce9d..bb57874103e 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -1,5 +1,5 @@ import { html, nothing } from "lit"; -import { t, i18n, SUPPORTED_LOCALES, type Locale } from "../../i18n/index.ts"; +import { t, i18n, SUPPORTED_LOCALES, type Locale, isSupportedLocale } from "../../i18n/index.ts"; import type { EventLogEntry } from "../app-events.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts"; import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; @@ -190,7 +190,9 @@ export function renderOverview(props: OverviewProps) { `; })(); - const currentLocale = i18n.getLocale(); + const currentLocale = isSupportedLocale(props.settings.locale) + ? props.settings.locale + : i18n.getLocale(); return html`
@@ -295,7 +297,9 @@ export function renderOverview(props: OverviewProps) { > ${SUPPORTED_LOCALES.map((loc) => { const key = loc.replace(/-([a-zA-Z])/g, (_, c) => c.toUpperCase()); - return html``; + return html``; })} diff --git a/vitest.config.ts b/vitest.config.ts index 564065be9e3..2ed4ed07f7c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,10 +13,6 @@ export default defineConfig({ resolve: { // Keep this ordered: the base `openclaw/plugin-sdk` alias is a prefix match. alias: [ - { - find: "openclaw/extension-api", - replacement: path.join(repoRoot, "src", "extensionAPI.ts"), - }, ...pluginSdkSubpaths.map((subpath) => ({ find: `openclaw/plugin-sdk/${subpath}`, replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`), @@ -86,7 +82,6 @@ export default defineConfig({ "src/index.ts", "src/runtime.ts", "src/channel-web.ts", - "src/extensionAPI.ts", "src/logging.ts", "src/cli/**", "src/commands/**",