From 6ba4d0ddc324a2bd7c27ff9c8a06f7f26955726f Mon Sep 17 00:00:00 2001 From: Clayton Shaw Date: Mon, 16 Mar 2026 22:57:45 +0000 Subject: [PATCH 001/128] fix: remove orphaned tool_result blocks during compaction (#15691) (#16095) Merged via squash. Prepared head SHA: b772432c1ff17f49fdfc747ba88e7ed297b08465 Co-authored-by: claw-sylphx <260243939+claw-sylphx@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/compact.ts | 21 ++- src/agents/session-transcript-repair.test.ts | 140 +++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9c1efc235..6b0876596fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,7 @@ Docs: https://docs.openclaw.ai - 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. ## 2026.3.13 diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index ba001a6746a..ea6bc0c5299 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -64,7 +64,10 @@ import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { resolveSandboxContext } from "../sandbox.js"; import { repairSessionFileIfNeeded } from "../session-file-repair.js"; import { guardSessionManager } from "../session-tool-result-guard-wrapper.js"; -import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js"; +import { + repairToolUseResultPairing, + sanitizeToolUseResultPairing, +} from "../session-transcript-repair.js"; import { acquireSessionWriteLock, resolveSessionLockMaxHoldFromTimeout, @@ -954,6 +957,22 @@ export async function compactEmbeddedPiSessionDirect( }, }, ); + // Re-run tool_use/tool_result pairing repair after compaction. + // Compaction can remove assistant messages containing tool_use blocks + // while leaving orphaned tool_result blocks behind, which causes + // Anthropic API 400 errors: "unexpected tool_use_id found in tool_result blocks". + // See: https://github.com/openclaw/openclaw/issues/15691 + if (transcriptPolicy.repairToolUseResultPairing) { + const postCompactRepair = repairToolUseResultPairing(session.messages); + if (postCompactRepair.droppedOrphanCount > 0 || postCompactRepair.moved) { + session.agent.replaceMessages(postCompactRepair.messages); + log.info( + `[compaction] post-compact repair: dropped ${postCompactRepair.droppedOrphanCount} orphaned tool_result(s), ` + + `${postCompactRepair.droppedDuplicateCount} duplicate(s) ` + + `(sessionKey=${params.sessionKey ?? params.sessionId})`, + ); + } + } await runPostCompactionSideEffects({ config: params.config, sessionKey: params.sessionKey, diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index eea82268d7d..6ed5fdedb73 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -488,3 +488,143 @@ describe("stripToolResultDetails", () => { expect(out).toBe(input); }); }); + +describe("post-compaction orphaned tool_result removal (#15691)", () => { + it("drops orphaned tool_result blocks left after compaction removes tool_use messages", () => { + const input = castAgentMessages([ + { + role: "assistant", + content: [{ type: "text", text: "Here is a summary of our earlier conversation..." }], + }, + { + role: "toolResult", + toolCallId: "toolu_compacted_1", + toolName: "Read", + content: [{ type: "text", text: "file contents" }], + isError: false, + }, + { + role: "toolResult", + toolCallId: "toolu_compacted_2", + toolName: "exec", + content: [{ type: "text", text: "command output" }], + isError: false, + }, + { role: "user", content: "now do something else" }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll read that file" }, + { type: "toolCall", id: "toolu_active_1", name: "Read", arguments: { path: "foo.ts" } }, + ], + }, + { + role: "toolResult", + toolCallId: "toolu_active_1", + toolName: "Read", + content: [{ type: "text", text: "actual content" }], + isError: false, + }, + ]); + + const result = repairToolUseResultPairing(input); + + expect(result.droppedOrphanCount).toBe(2); + const toolResults = result.messages.filter((message) => message.role === "toolResult"); + expect(toolResults).toHaveLength(1); + expect((toolResults[0] as { toolCallId?: string }).toolCallId).toBe("toolu_active_1"); + expect(result.messages.map((message) => message.role)).toEqual([ + "assistant", + "user", + "assistant", + "toolResult", + ]); + }); + + it("handles synthetic tool_result from interrupted request after compaction", () => { + const input = castAgentMessages([ + { + role: "assistant", + content: [{ type: "text", text: "Compaction summary of previous conversation." }], + }, + { + role: "toolResult", + toolCallId: "toolu_interrupted", + toolName: "unknown", + content: [ + { + type: "text", + text: "[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.", + }, + ], + isError: true, + }, + { role: "user", content: "continue please" }, + ]); + + const result = repairToolUseResultPairing(input); + + expect(result.droppedOrphanCount).toBe(1); + expect(result.messages.some((message) => message.role === "toolResult")).toBe(false); + expect(result.messages.map((message) => message.role)).toEqual(["assistant", "user"]); + }); + + it("preserves valid tool_use/tool_result pairs while removing orphans", () => { + const input = castAgentMessages([ + { + role: "assistant", + content: [ + { type: "toolCall", id: "toolu_valid", name: "Read", arguments: { path: "a.ts" } }, + ], + }, + { + role: "toolResult", + toolCallId: "toolu_valid", + toolName: "Read", + content: [{ type: "text", text: "content of a.ts" }], + isError: false, + }, + { role: "user", content: "thanks, what about b.ts?" }, + { + role: "toolResult", + toolCallId: "toolu_gone", + toolName: "Read", + content: [{ type: "text", text: "content of old file" }], + isError: false, + }, + { + role: "assistant", + content: [{ type: "text", text: "Let me check b.ts" }], + }, + ]); + + const result = repairToolUseResultPairing(input); + + expect(result.droppedOrphanCount).toBe(1); + const toolResults = result.messages.filter((message) => message.role === "toolResult"); + expect(toolResults).toHaveLength(1); + expect((toolResults[0] as { toolCallId?: string }).toolCallId).toBe("toolu_valid"); + }); + + it("returns original array when no orphans exist", () => { + const input = castAgentMessages([ + { + role: "assistant", + content: [{ type: "toolCall", id: "toolu_1", name: "Read", arguments: { path: "x.ts" } }], + }, + { + role: "toolResult", + toolCallId: "toolu_1", + toolName: "Read", + content: [{ type: "text", text: "ok" }], + isError: false, + }, + { role: "user", content: "good" }, + ]); + + const result = repairToolUseResultPairing(input); + + expect(result.droppedOrphanCount).toBe(0); + expect(result.messages).toStrictEqual(input); + }); +}); From 4863b651c6dc8ab238465ed1c4b8b2556882a0b6 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:50:31 -0500 Subject: [PATCH 002/128] docs: rename onboarding user-facing wizard copy Co-authored-by: Tak --- CHANGELOG.md | 3 - README.md | 14 +- docs/channels/bluebubbles.md | 2 +- docs/channels/feishu.md | 4 +- docs/channels/nostr.md | 2 +- docs/channels/telegram.md | 2 +- docs/cli/index.md | 12 +- docs/cli/onboard.md | 6 +- docs/cli/setup.md | 6 +- docs/concepts/models.md | 4 +- docs/gateway/authentication.md | 2 +- docs/gateway/configuration-reference.md | 2 +- docs/gateway/configuration.md | 2 +- docs/gateway/security/index.md | 2 +- docs/help/faq.md | 22 +- docs/index.md | 2 +- docs/install/docker.md | 2 +- docs/install/index.md | 2 +- docs/install/northflank.mdx | 4 +- docs/install/railway.mdx | 6 +- docs/install/render.mdx | 2 +- docs/install/updating.md | 2 +- docs/platforms/raspberry-pi.md | 2 +- docs/providers/ollama.md | 6 +- docs/reference/wizard.md | 38 +-- docs/start/getting-started.md | 10 +- docs/start/hubs.md | 2 +- docs/start/onboarding-overview.md | 12 +- docs/start/quickstart.md | 4 +- docs/start/setup.md | 2 +- docs/start/wizard-cli-automation.md | 4 +- docs/start/wizard-cli-reference.md | 4 +- docs/start/wizard.md | 26 +- docs/web/control-ui.md | 2 +- docs/zh-CN/cli/index.md | 12 +- docs/zh-CN/cli/onboard.md | 6 +- docs/zh-CN/cli/setup.md | 6 +- docs/zh-CN/help/faq.md | 24 +- docs/zh-CN/index.md | 2 +- docs/zh-CN/start/getting-started.md | 10 +- docs/zh-CN/start/hubs.md | 2 +- docs/zh-CN/start/onboarding-overview.md | 12 +- docs/zh-CN/start/wizard.md | 26 +- .../src/auto-reply/monitor/group-gating.ts | 7 +- .../auto-reply/web-auto-reply-monitor.test.ts | 25 +- extensions/whatsapp/src/inbound/monitor.ts | 2 - extensions/whatsapp/src/inbound/types.ts | 1 - ...tor-inbox.streams-inbound-messages.test.ts | 14 +- .../src/monitor-inbox.test-harness.ts | 4 +- src/agents/pi-embedded-runner/compact.ts | 21 +- .../compaction-safeguard.test.ts | 132 --------- .../pi-extensions/compaction-safeguard.ts | 253 +----------------- src/agents/session-transcript-repair.test.ts | 140 ---------- src/cli/config-cli.ts | 4 +- src/cli/program/command-registry.ts | 6 +- src/cli/program/core-command-descriptors.ts | 6 +- src/cli/program/register.configure.ts | 2 +- src/cli/program/register.onboard.ts | 8 +- src/cli/program/register.setup.ts | 6 +- .../auth-choice.apply.plugin-provider.ts | 2 +- src/cron/isolated-agent/subagent-followup.ts | 22 +- ...rovider-usage.auth.normalizes-keys.test.ts | 7 + src/plugins/bundle-mcp.test.ts | 5 +- src/plugins/marketplace.test.ts | 31 +-- 64 files changed, 235 insertions(+), 780 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b0876596fd..3534d41f0e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,7 +105,6 @@ Docs: https://docs.openclaw.ai - 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. ## 2026.3.13 @@ -184,7 +183,6 @@ Docs: https://docs.openclaw.ai - Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057) - Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy. - Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar. -- WhatsApp/group replies: recognize implicit reply-to-bot mentions when WhatsApp sends the quoted sender in `@lid` format, including device-suffixed self identities. (#23029) Thanks @sparkyrider. ## 2026.3.12 @@ -276,7 +274,6 @@ 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. ## 2026.3.11 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/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/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 170c0a94219..235d4a18a7b 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2950,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/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/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/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/whatsapp/src/auto-reply/monitor/group-gating.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts index 4f40bda112c..418d5ebee83 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts @@ -127,17 +127,14 @@ export function applyGroupGating(params: ApplyGroupGatingParams) { conversationId: params.conversationId, }); const requireMention = activation !== "always"; - const selfJid = params.msg.selfJid?.replace(/:\d+/, ""); - const selfLid = params.msg.selfLid?.replace(/:\d+/, ""); - // replyToSenderJid may carry either a standard JID or an @lid identifier. - const replySenderJid = params.msg.replyToSenderJid?.replace(/:\d+/, ""); + const selfJid = params.msg.selfJid?.replace(/:\\d+/, ""); + const replySenderJid = params.msg.replyToSenderJid?.replace(/:\\d+/, ""); const selfE164 = params.msg.selfE164 ? normalizeE164(params.msg.selfE164) : null; const replySenderE164 = params.msg.replyToSenderE164 ? normalizeE164(params.msg.replyToSenderE164) : null; const implicitMention = Boolean( (selfJid && replySenderJid && selfJid === replySenderJid) || - (selfLid && replySenderJid && selfLid === replySenderJid) || (selfE164 && replySenderE164 && selfE164 === replySenderE164), ); const mentionGate = resolveMentionGating({ diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts index 474b2215066..412648b3180 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts @@ -112,7 +112,7 @@ describe("applyGroupGating", () => { accountId: "default", body: "following up", timestamp: Date.now(), - selfJid: "15551234567:1@s.whatsapp.net", + selfJid: "15551234567@s.whatsapp.net", selfE164: "+15551234567", replyToId: "m0", replyToBody: "bot said hi", @@ -125,29 +125,6 @@ describe("applyGroupGating", () => { expect(result.shouldProcess).toBe(true); }); - it("treats LID-format reply-to-bot as implicit mention", () => { - const cfg = makeConfig({}); - const { result } = runGroupGating({ - cfg, - msg: createGroupMessage({ - id: "m1-lid", - to: "+15550000", - accountId: "default", - body: "following up", - timestamp: Date.now(), - selfJid: "15551234567@s.whatsapp.net", - selfLid: "1234567890123:1@lid", - selfE164: "+15551234567", - replyToId: "m0", - replyToBody: "bot said hi", - replyToSender: "1234567890123@lid", - replyToSenderJid: "1234567890123@lid", - }), - }); - - expect(result.shouldProcess).toBe(true); - }); - it.each([ { id: "g-new", command: "/new" }, { id: "g-status", command: "/status" }, diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 990d8a65f4a..5337c5d6a43 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -66,7 +66,6 @@ export async function monitorWebInbox(options: { } const selfJid = sock.user?.id; - const selfLid = sock.user?.lid; const selfE164 = selfJid ? jidToE164(selfJid) : null; const debouncer = createInboundDebouncer({ debounceMs: options.debounceMs ?? 0, @@ -373,7 +372,6 @@ export async function monitorWebInbox(options: { groupParticipants: inbound.groupParticipants, mentionedJids: mentionedJids ?? undefined, selfJid, - selfLid, selfE164, fromMe: Boolean(msg.key?.fromMe), location: enriched.location ?? undefined, diff --git a/extensions/whatsapp/src/inbound/types.ts b/extensions/whatsapp/src/inbound/types.ts index 25a7edaae0c..c9c97810bad 100644 --- a/extensions/whatsapp/src/inbound/types.ts +++ b/extensions/whatsapp/src/inbound/types.ts @@ -30,7 +30,6 @@ export type WebInboundMessage = { groupParticipants?: string[]; mentionedJids?: string[]; selfJid?: string | null; - selfLid?: string | null; selfE164?: string | null; fromMe?: boolean; location?: NormalizedLocation; diff --git a/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts index 427d7bd1d86..7e8b5c26887 100644 --- a/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts +++ b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts @@ -118,12 +118,7 @@ describe("web monitor inbox", () => { await tick(); expect(onMessage).toHaveBeenCalledWith( - expect.objectContaining({ - body: "ping", - from: "+999", - to: "+123", - selfLid: "123:1@lid", - }), + expect.objectContaining({ body: "ping", from: "+999", to: "+123" }), ); expect(sock.readMessages).toHaveBeenCalledWith([ { @@ -186,12 +181,7 @@ describe("web monitor inbox", () => { expect(getPNForLID).toHaveBeenCalledWith("999@lid"); expect(onMessage).toHaveBeenCalledWith( - expect.objectContaining({ - body: "ping", - from: "+999", - to: "+123", - selfLid: "123:1@lid", - }), + expect.objectContaining({ body: "ping", from: "+999", to: "+123" }), ); await listener.close(); diff --git a/extensions/whatsapp/src/monitor-inbox.test-harness.ts b/extensions/whatsapp/src/monitor-inbox.test-harness.ts index 5dc61e9e4ac..43bc731c459 100644 --- a/extensions/whatsapp/src/monitor-inbox.test-harness.ts +++ b/extensions/whatsapp/src/monitor-inbox.test-harness.ts @@ -44,7 +44,7 @@ export type MockSock = { getPNForLID: AnyMockFn; }; }; - user: { id: string; lid?: string }; + user: { id: string }; }; function createResolvedMock() { @@ -66,7 +66,7 @@ function createMockSock(): MockSock { getPNForLID: vi.fn().mockResolvedValue(null), }, }, - user: { id: "123@s.whatsapp.net", lid: "123:1@lid" }, + user: { id: "123@s.whatsapp.net" }, }; } diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index ea6bc0c5299..ba001a6746a 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -64,10 +64,7 @@ import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { resolveSandboxContext } from "../sandbox.js"; import { repairSessionFileIfNeeded } from "../session-file-repair.js"; import { guardSessionManager } from "../session-tool-result-guard-wrapper.js"; -import { - repairToolUseResultPairing, - sanitizeToolUseResultPairing, -} from "../session-transcript-repair.js"; +import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js"; import { acquireSessionWriteLock, resolveSessionLockMaxHoldFromTimeout, @@ -957,22 +954,6 @@ export async function compactEmbeddedPiSessionDirect( }, }, ); - // Re-run tool_use/tool_result pairing repair after compaction. - // Compaction can remove assistant messages containing tool_use blocks - // while leaving orphaned tool_result blocks behind, which causes - // Anthropic API 400 errors: "unexpected tool_use_id found in tool_result blocks". - // See: https://github.com/openclaw/openclaw/issues/15691 - if (transcriptPolicy.repairToolUseResultPairing) { - const postCompactRepair = repairToolUseResultPairing(session.messages); - if (postCompactRepair.droppedOrphanCount > 0 || postCompactRepair.moved) { - session.agent.replaceMessages(postCompactRepair.messages); - log.info( - `[compaction] post-compact repair: dropped ${postCompactRepair.droppedOrphanCount} orphaned tool_result(s), ` + - `${postCompactRepair.droppedDuplicateCount} duplicate(s) ` + - `(sessionKey=${params.sessionKey ?? params.sessionId})`, - ); - } - } await runPostCompactionSideEffects({ config: params.config, sessionKey: params.sessionKey, diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index 0869d05eb3f..882099f3569 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -28,8 +28,6 @@ const mockSummarizeInStages = vi.mocked(compactionModule.summarizeInStages); const { collectToolFailures, formatToolFailuresSection, - trimToolResultsForSummarization, - restoreOriginalToolResultsForKeptMessages, splitPreservedRecentTurns, formatPreservedTurnsSection, buildCompactionStructureInstructions, @@ -47,26 +45,6 @@ const { SAFETY_MARGIN, } = __testing; -function readTextBlocks(message: AgentMessage): string { - const content = (message as { content?: unknown }).content; - if (typeof content === "string") { - return content; - } - if (!Array.isArray(content)) { - return ""; - } - return content - .map((block) => { - if (!block || typeof block !== "object") { - return ""; - } - const text = (block as { text?: unknown }).text; - return typeof text === "string" ? text : ""; - }) - .filter(Boolean) - .join("\n"); -} - function stubSessionManager(): ExtensionContext["sessionManager"] { const stub: ExtensionContext["sessionManager"] = { getCwd: () => "/stub", @@ -256,116 +234,6 @@ describe("compaction-safeguard tool failures", () => { }); }); -describe("compaction-safeguard toolResult trimming", () => { - it("truncates oversized tool results and compacts older entries to stay within budget", () => { - const messages: AgentMessage[] = Array.from({ length: 9 }, (_unused, index) => ({ - role: "toolResult", - toolCallId: `call-${index}`, - toolName: "read", - content: [ - { - type: "text", - text: `head-${index}\n${"x".repeat(25_000)}\ntail-${index}`, - }, - ], - timestamp: index + 1, - })) as AgentMessage[]; - - const trimmed = trimToolResultsForSummarization(messages); - - expect(trimmed.stats.truncatedCount).toBe(9); - expect(trimmed.stats.compactedCount).toBe(1); - expect(readTextBlocks(trimmed.messages[0])).toBe(""); - expect(trimmed.stats.afterChars).toBeLessThan(trimmed.stats.beforeChars); - expect(readTextBlocks(trimmed.messages[8])).toContain("head-8"); - expect(readTextBlocks(trimmed.messages[8])).toContain( - "[...tool result truncated for compaction budget...]", - ); - expect(readTextBlocks(trimmed.messages[8])).toContain("tail-8"); - }); - - it("restores kept tool results after prune for both toolCallId and toolUseId", () => { - const originalMessages: AgentMessage[] = [ - { role: "user", content: "keep these tool results", timestamp: 1 }, - { - role: "toolResult", - toolCallId: "call-1", - toolName: "read", - content: [{ type: "text", text: "original call payload" }], - timestamp: 2, - } as AgentMessage, - { - role: "toolResult", - toolUseId: "use-1", - toolName: "exec", - content: [{ type: "text", text: "original use payload" }], - timestamp: 3, - } as unknown as AgentMessage, - ]; - const prunedMessages: AgentMessage[] = [ - originalMessages[0], - { - role: "toolResult", - toolCallId: "call-1", - toolName: "read", - content: [{ type: "text", text: "trimmed call payload" }], - timestamp: 2, - } as AgentMessage, - { - role: "toolResult", - toolUseId: "use-1", - toolName: "exec", - content: [{ type: "text", text: "trimmed use payload" }], - timestamp: 3, - } as unknown as AgentMessage, - ]; - - const restored = restoreOriginalToolResultsForKeptMessages({ - prunedMessages, - originalMessages, - }); - - expect(readTextBlocks(restored[1])).toBe("original call payload"); - expect(readTextBlocks(restored[2])).toBe("original use payload"); - }); - - it("extracts identifiers from the trimmed kept payloads after prune restore", () => { - const hiddenIdentifier = "DEADBEEF12345678"; - const restored = restoreOriginalToolResultsForKeptMessages({ - prunedMessages: [ - { role: "user", content: "recent ask", timestamp: 1 }, - { - role: "toolResult", - toolCallId: "call-1", - toolName: "read", - content: [{ type: "text", text: "placeholder" }], - timestamp: 2, - } as AgentMessage, - ], - originalMessages: [ - { role: "user", content: "recent ask", timestamp: 1 }, - { - role: "toolResult", - toolCallId: "call-1", - toolName: "read", - content: [ - { - type: "text", - text: `visible head ${"a".repeat(16_000)}${hiddenIdentifier}${"b".repeat(16_000)} visible tail`, - }, - ], - timestamp: 2, - } as AgentMessage, - ], - }); - - const trimmed = trimToolResultsForSummarization(restored).messages; - const identifierSeedText = trimmed.map((message) => readTextBlocks(message)).join("\n"); - - expect(extractOpaqueIdentifiers(identifierSeedText)).not.toContain(hiddenIdentifier); - }); -}); - describe("computeAdaptiveChunkRatio", () => { const CONTEXT_WINDOW = 200_000; diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index a8c73f2efcd..4461b97d3e0 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -407,179 +407,6 @@ function formatPreservedTurnsSection(messages: AgentMessage[]): string { return `\n\n## Recent turns preserved verbatim\n${lines.join("\n")}`; } -type ToolResultSummaryTrimStats = { - truncatedCount: number; - compactedCount: number; - beforeChars: number; - afterChars: number; -}; - -const COMPACTION_SUMMARY_TOOL_RESULT_MAX_CHARS = 2_500; -const COMPACTION_SUMMARY_TOOL_RESULT_TOTAL_CHARS_BUDGET = 20_000; -const COMPACTION_SUMMARY_TOOL_RESULT_TRUNCATION_NOTICE = - "[...tool result truncated for compaction budget...]"; -const COMPACTION_SUMMARY_TOOL_RESULT_COMPACTED_NOTICE = - "[tool result compacted due to global compaction budget]"; -const COMPACTION_SUMMARY_TOOL_RESULT_NON_TEXT_NOTICE = "[non-text tool result content omitted]"; - -function getToolResultTextFromContent(content: unknown): string { - if (!Array.isArray(content)) { - return ""; - } - const parts: string[] = []; - for (const block of content) { - if (!block || typeof block !== "object") { - continue; - } - const text = (block as { text?: unknown }).text; - if (typeof text === "string" && text.length > 0) { - parts.push(text); - } - } - return parts.join("\n"); -} - -function hasNonTextToolResultContent(content: unknown): boolean { - if (!Array.isArray(content)) { - return false; - } - return content.some((block) => { - if (!block || typeof block !== "object") { - return false; - } - const t = (block as { type?: unknown }).type; - return t !== "text"; - }); -} - -function replaceToolResultContentForSummary(msg: AgentMessage, text: string): AgentMessage { - return { - ...(msg as unknown as Record), - content: [{ type: "text", text }], - } as AgentMessage; -} - -function trimToolResultsForSummarization(messages: AgentMessage[]): { - messages: AgentMessage[]; - stats: ToolResultSummaryTrimStats; -} { - const next = [...messages]; - let truncatedCount = 0; - let compactedCount = 0; - let beforeChars = 0; - - for (let i = 0; i < next.length; i += 1) { - const msg = next[i]; - if ((msg as { role?: unknown }).role !== "toolResult") { - continue; - } - const content = (msg as { content?: unknown }).content; - const text = getToolResultTextFromContent(content); - const hasNonText = hasNonTextToolResultContent(content); - beforeChars += text.length; - - let normalized = text; - if (normalized.length === 0 && hasNonText) { - normalized = COMPACTION_SUMMARY_TOOL_RESULT_NON_TEXT_NOTICE; - } - - if (normalized.length > COMPACTION_SUMMARY_TOOL_RESULT_MAX_CHARS) { - const separator = `\n\n${COMPACTION_SUMMARY_TOOL_RESULT_TRUNCATION_NOTICE}\n\n`; - const available = Math.max(0, COMPACTION_SUMMARY_TOOL_RESULT_MAX_CHARS - separator.length); - const tailBudget = Math.floor(available * 0.35); - const headBudget = Math.max(0, available - tailBudget); - const head = normalized.slice(0, headBudget); - const tail = tailBudget > 0 ? normalized.slice(-tailBudget) : ""; - normalized = `${head}${separator}${tail}`; - truncatedCount += 1; - } - - if (hasNonText || normalized !== text) { - next[i] = replaceToolResultContentForSummary(msg, normalized); - } - } - - let runningChars = 0; - for (let i = next.length - 1; i >= 0; i -= 1) { - const msg = next[i]; - if ((msg as { role?: unknown }).role !== "toolResult") { - continue; - } - const text = getToolResultTextFromContent((msg as { content?: unknown }).content); - if (runningChars + text.length <= COMPACTION_SUMMARY_TOOL_RESULT_TOTAL_CHARS_BUDGET) { - runningChars += text.length; - continue; - } - const placeholderLen = COMPACTION_SUMMARY_TOOL_RESULT_COMPACTED_NOTICE.length; - const remainingBudget = Math.max( - 0, - COMPACTION_SUMMARY_TOOL_RESULT_TOTAL_CHARS_BUDGET - runningChars, - ); - const replacementText = - remainingBudget >= placeholderLen ? COMPACTION_SUMMARY_TOOL_RESULT_COMPACTED_NOTICE : ""; - next[i] = replaceToolResultContentForSummary(msg, replacementText); - runningChars += replacementText.length; - compactedCount += 1; - } - - let afterChars = 0; - for (const msg of next) { - if ((msg as { role?: unknown }).role !== "toolResult") { - continue; - } - afterChars += getToolResultTextFromContent((msg as { content?: unknown }).content).length; - } - - return { - messages: next, - stats: { truncatedCount, compactedCount, beforeChars, afterChars }, - }; -} - -function getToolResultStableId(message: AgentMessage): string | null { - if ((message as { role?: unknown }).role !== "toolResult") { - return null; - } - const toolCallId = (message as { toolCallId?: unknown }).toolCallId; - if (typeof toolCallId === "string" && toolCallId.length > 0) { - return `call:${toolCallId}`; - } - const toolUseId = (message as { toolUseId?: unknown }).toolUseId; - if (typeof toolUseId === "string" && toolUseId.length > 0) { - return `use:${toolUseId}`; - } - return null; -} - -function restoreOriginalToolResultsForKeptMessages(params: { - prunedMessages: AgentMessage[]; - originalMessages: AgentMessage[]; -}): AgentMessage[] { - const originalByStableId = new Map(); - for (const message of params.originalMessages) { - const stableId = getToolResultStableId(message); - if (!stableId) { - continue; - } - const bucket = originalByStableId.get(stableId) ?? []; - bucket.push(message); - originalByStableId.set(stableId, bucket); - } - - return params.prunedMessages.map((message) => { - const stableId = getToolResultStableId(message); - if (!stableId) { - return message; - } - const bucket = originalByStableId.get(stableId); - if (!bucket || bucket.length === 0) { - return message; - } - const restored = bucket.shift(); - return restored ?? message; - }); -} - function wrapUntrustedInstructionBlock(label: string, text: string): string { return wrapUntrustedPromptDataBlock({ label, @@ -928,18 +755,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { const modelContextWindow = resolveContextWindowTokens(model); const contextWindowTokens = runtime?.contextWindowTokens ?? modelContextWindow; const turnPrefixMessages = preparation.turnPrefixMessages ?? []; - const prefixTrimmedForBudget = trimToolResultsForSummarization(turnPrefixMessages); - if ( - prefixTrimmedForBudget.stats.truncatedCount > 0 || - prefixTrimmedForBudget.stats.compactedCount > 0 - ) { - log.warn( - `Compaction safeguard: pre-trimmed prefix toolResult payloads for budgeting ` + - `(truncated=${prefixTrimmedForBudget.stats.truncatedCount}, compacted=${prefixTrimmedForBudget.stats.compactedCount}, ` + - `chars=${prefixTrimmedForBudget.stats.beforeChars}->${prefixTrimmedForBudget.stats.afterChars})`, - ); - } - const prefixMessagesForSummary = prefixTrimmedForBudget.messages; let messagesToSummarize = preparation.messagesToSummarize; const recentTurnsPreserve = resolveRecentTurnsPreserve(runtime?.recentTurnsPreserve); const qualityGuardEnabled = runtime?.qualityGuardEnabled ?? false; @@ -959,44 +774,28 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { let droppedSummary: string | undefined; if (tokensBefore !== undefined) { - const budgetTrimmedForSummary = trimToolResultsForSummarization(messagesToSummarize); - if ( - budgetTrimmedForSummary.stats.truncatedCount > 0 || - budgetTrimmedForSummary.stats.compactedCount > 0 - ) { - log.warn( - `Compaction safeguard: pre-trimmed toolResult payloads for budgeting ` + - `(truncated=${budgetTrimmedForSummary.stats.truncatedCount}, compacted=${budgetTrimmedForSummary.stats.compactedCount}, ` + - `chars=${budgetTrimmedForSummary.stats.beforeChars}->${budgetTrimmedForSummary.stats.afterChars})`, - ); - } const summarizableTokens = - estimateMessagesTokens(budgetTrimmedForSummary.messages) + - estimateMessagesTokens(prefixMessagesForSummary); + estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages); const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens)); // Apply SAFETY_MARGIN so token underestimates don't trigger unnecessary pruning const maxHistoryTokens = Math.floor(contextWindowTokens * maxHistoryShare * SAFETY_MARGIN); if (newContentTokens > maxHistoryTokens) { - const originalMessagesBeforePrune = messagesToSummarize; const pruned = pruneHistoryForContextShare({ - messages: budgetTrimmedForSummary.messages, + messages: messagesToSummarize, maxContextTokens: contextWindowTokens, maxHistoryShare, parts: 2, }); if (pruned.droppedChunks > 0) { - const historyRatio = (summarizableTokens / contextWindowTokens) * 100; + const newContentRatio = (newContentTokens / contextWindowTokens) * 100; log.warn( - `Compaction safeguard: summarizable history uses ${historyRatio.toFixed( + `Compaction safeguard: new content uses ${newContentRatio.toFixed( 1, )}% of context; dropped ${pruned.droppedChunks} older chunk(s) ` + `(${pruned.droppedMessages} messages) to fit history budget.`, ); - messagesToSummarize = restoreOriginalToolResultsForKeptMessages({ - prunedMessages: pruned.messages, - originalMessages: originalMessagesBeforePrune, - }); + messagesToSummarize = pruned.messages; // Summarize dropped messages so context isn't lost if (pruned.droppedMessagesList.length > 0) { @@ -1010,19 +809,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { Math.floor(contextWindowTokens * droppedChunkRatio) - SUMMARIZATION_OVERHEAD_TOKENS, ); - const droppedTrimmed = trimToolResultsForSummarization(pruned.droppedMessagesList); - if ( - droppedTrimmed.stats.truncatedCount > 0 || - droppedTrimmed.stats.compactedCount > 0 - ) { - log.warn( - `Compaction safeguard: trimmed dropped toolResult payloads before summarize ` + - `(truncated=${droppedTrimmed.stats.truncatedCount}, compacted=${droppedTrimmed.stats.compactedCount}, ` + - `chars=${droppedTrimmed.stats.beforeChars}->${droppedTrimmed.stats.afterChars})`, - ); - } droppedSummary = await summarizeInStages({ - messages: droppedTrimmed.messages, + messages: pruned.droppedMessagesList, model, apiKey, signal, @@ -1054,21 +842,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { }); messagesToSummarize = summaryTargetMessages; const preservedTurnsSection = formatPreservedTurnsSection(preservedRecentMessages); - const latestUserAsk = extractLatestUserAsk([ - ...messagesToSummarize, - ...prefixMessagesForSummary, - ]); - const summaryTrimmed = trimToolResultsForSummarization(messagesToSummarize); - if (summaryTrimmed.stats.truncatedCount > 0 || summaryTrimmed.stats.compactedCount > 0) { - log.warn( - `Compaction safeguard: trimmed toolResult payloads before summarize ` + - `(truncated=${summaryTrimmed.stats.truncatedCount}, compacted=${summaryTrimmed.stats.compactedCount}, ` + - `chars=${summaryTrimmed.stats.beforeChars}->${summaryTrimmed.stats.afterChars})`, - ); - } - - const identifierSourceMessages = [...summaryTrimmed.messages, ...prefixMessagesForSummary]; - const identifierSeedText = identifierSourceMessages + const latestUserAsk = extractLatestUserAsk([...messagesToSummarize, ...turnPrefixMessages]); + const identifierSeedText = [...messagesToSummarize, ...turnPrefixMessages] .slice(-10) .map((message) => extractMessageText(message)) .filter(Boolean) @@ -1078,7 +853,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { // Use adaptive chunk ratio based on message sizes, reserving headroom for // the summarization prompt, system prompt, previous summary, and reasoning budget // that generateSummary adds on top of the serialized conversation chunk. - const allMessages = [...summaryTrimmed.messages, ...prefixMessagesForSummary]; + const allMessages = [...messagesToSummarize, ...turnPrefixMessages]; const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens); const maxChunkTokens = Math.max( 1, @@ -1100,9 +875,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { let summaryWithPreservedTurns = ""; try { const historySummary = - summaryTrimmed.messages.length > 0 + messagesToSummarize.length > 0 ? await summarizeInStages({ - messages: summaryTrimmed.messages, + messages: messagesToSummarize, model, apiKey, signal, @@ -1116,9 +891,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { : buildStructuredFallbackSummary(effectivePreviousSummary, summarizationInstructions); summaryWithoutPreservedTurns = historySummary; - if (preparation.isSplitTurn && prefixMessagesForSummary.length > 0) { + if (preparation.isSplitTurn && turnPrefixMessages.length > 0) { const prefixSummary = await summarizeInStages({ - messages: prefixMessagesForSummary, + messages: turnPrefixMessages, model, apiKey, signal, @@ -1218,8 +993,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { export const __testing = { collectToolFailures, formatToolFailuresSection, - trimToolResultsForSummarization, - restoreOriginalToolResultsForKeptMessages, splitPreservedRecentTurns, formatPreservedTurnsSection, buildCompactionStructureInstructions, diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index 6ed5fdedb73..eea82268d7d 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -488,143 +488,3 @@ describe("stripToolResultDetails", () => { expect(out).toBe(input); }); }); - -describe("post-compaction orphaned tool_result removal (#15691)", () => { - it("drops orphaned tool_result blocks left after compaction removes tool_use messages", () => { - const input = castAgentMessages([ - { - role: "assistant", - content: [{ type: "text", text: "Here is a summary of our earlier conversation..." }], - }, - { - role: "toolResult", - toolCallId: "toolu_compacted_1", - toolName: "Read", - content: [{ type: "text", text: "file contents" }], - isError: false, - }, - { - role: "toolResult", - toolCallId: "toolu_compacted_2", - toolName: "exec", - content: [{ type: "text", text: "command output" }], - isError: false, - }, - { role: "user", content: "now do something else" }, - { - role: "assistant", - content: [ - { type: "text", text: "I'll read that file" }, - { type: "toolCall", id: "toolu_active_1", name: "Read", arguments: { path: "foo.ts" } }, - ], - }, - { - role: "toolResult", - toolCallId: "toolu_active_1", - toolName: "Read", - content: [{ type: "text", text: "actual content" }], - isError: false, - }, - ]); - - const result = repairToolUseResultPairing(input); - - expect(result.droppedOrphanCount).toBe(2); - const toolResults = result.messages.filter((message) => message.role === "toolResult"); - expect(toolResults).toHaveLength(1); - expect((toolResults[0] as { toolCallId?: string }).toolCallId).toBe("toolu_active_1"); - expect(result.messages.map((message) => message.role)).toEqual([ - "assistant", - "user", - "assistant", - "toolResult", - ]); - }); - - it("handles synthetic tool_result from interrupted request after compaction", () => { - const input = castAgentMessages([ - { - role: "assistant", - content: [{ type: "text", text: "Compaction summary of previous conversation." }], - }, - { - role: "toolResult", - toolCallId: "toolu_interrupted", - toolName: "unknown", - content: [ - { - type: "text", - text: "[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.", - }, - ], - isError: true, - }, - { role: "user", content: "continue please" }, - ]); - - const result = repairToolUseResultPairing(input); - - expect(result.droppedOrphanCount).toBe(1); - expect(result.messages.some((message) => message.role === "toolResult")).toBe(false); - expect(result.messages.map((message) => message.role)).toEqual(["assistant", "user"]); - }); - - it("preserves valid tool_use/tool_result pairs while removing orphans", () => { - const input = castAgentMessages([ - { - role: "assistant", - content: [ - { type: "toolCall", id: "toolu_valid", name: "Read", arguments: { path: "a.ts" } }, - ], - }, - { - role: "toolResult", - toolCallId: "toolu_valid", - toolName: "Read", - content: [{ type: "text", text: "content of a.ts" }], - isError: false, - }, - { role: "user", content: "thanks, what about b.ts?" }, - { - role: "toolResult", - toolCallId: "toolu_gone", - toolName: "Read", - content: [{ type: "text", text: "content of old file" }], - isError: false, - }, - { - role: "assistant", - content: [{ type: "text", text: "Let me check b.ts" }], - }, - ]); - - const result = repairToolUseResultPairing(input); - - expect(result.droppedOrphanCount).toBe(1); - const toolResults = result.messages.filter((message) => message.role === "toolResult"); - expect(toolResults).toHaveLength(1); - expect((toolResults[0] as { toolCallId?: string }).toolCallId).toBe("toolu_valid"); - }); - - it("returns original array when no orphans exist", () => { - const input = castAgentMessages([ - { - role: "assistant", - content: [{ type: "toolCall", id: "toolu_1", name: "Read", arguments: { path: "x.ts" } }], - }, - { - role: "toolResult", - toolCallId: "toolu_1", - toolName: "Read", - content: [{ type: "text", text: "ok" }], - isError: false, - }, - { role: "user", content: "good" }, - ]); - - const result = repairToolUseResultPairing(input); - - expect(result.droppedOrphanCount).toBe(0); - expect(result.messages).toStrictEqual(input); - }); -}); diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 0469952d322..5167658040a 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -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/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/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/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/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/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/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index ef109f4abfb..4285b64e660 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -73,10 +73,13 @@ describe("loadEnabledBundleMcpConfig", () => { cfg: config, }); const resolvedServerPath = await fs.realpath(serverPath); + const loadedServerPath = loaded.config.mcpServers.bundleProbe?.args?.[0]; expect(loaded.diagnostics).toEqual([]); expect(loaded.config.mcpServers.bundleProbe?.command).toBe("node"); - expect(loaded.config.mcpServers.bundleProbe?.args).toEqual([resolvedServerPath]); + expect(loaded.config.mcpServers.bundleProbe?.args).toHaveLength(1); + expect(loadedServerPath).toBeDefined(); + expect(await fs.realpath(loadedServerPath as string)).toBe(resolvedServerPath); } finally { env.restore(); } 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" }, + }, + ], }); }); }); From 94c27f34a1851401b52b19f6e19009c9754f0a2f Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Mon, 16 Mar 2026 20:58:58 -0400 Subject: [PATCH 003/128] fix(plugins): keep built plugin loading on one module graph (#48595) --- scripts/stage-bundled-plugin-runtime.mjs | 117 ++++++++----- src/infra/tsdown-config.test.ts | 58 +++++++ src/plugin-sdk/root-alias.cjs | 3 + src/plugin-sdk/root-alias.test.ts | 8 +- src/plugins/loader.test.ts | 19 +++ src/plugins/loader.ts | 26 ++- .../stage-bundled-plugin-runtime.test.ts | 154 ++++++++++++++++-- tsdown.config.ts | 94 ++++++++--- 8 files changed, 385 insertions(+), 94 deletions(-) create mode 100644 src/infra/tsdown-config.test.ts diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index 134c76699c9..d6585d3191a 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -3,57 +3,86 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { removePathIfExists } from "./runtime-postbuild-shared.mjs"; -function linkOrCopyFile(sourcePath, targetPath) { - try { - fs.linkSync(sourcePath, targetPath); - } catch (error) { - if (error && typeof error === "object" && "code" in error) { - const code = error.code; - if (code === "EXDEV" || code === "EPERM" || code === "EMLINK") { - fs.copyFileSync(sourcePath, targetPath); - return; - } - } - throw error; - } +function symlinkType() { + return process.platform === "win32" ? "junction" : "dir"; } -function mirrorTreeWithHardlinks(sourceRoot, targetRoot) { - fs.mkdirSync(targetRoot, { recursive: true }); - const queue = [{ sourceDir: sourceRoot, targetDir: targetRoot }]; +function relativeSymlinkTarget(sourcePath, targetPath) { + const relativeTarget = path.relative(path.dirname(targetPath), sourcePath); + return relativeTarget || "."; +} - while (queue.length > 0) { - const current = queue.pop(); - if (!current) { +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; } - for (const dirent of fs.readdirSync(current.sourceDir, { withFileTypes: true })) { - const sourcePath = path.join(current.sourceDir, dirent.name); - const targetPath = path.join(current.targetDir, dirent.name); + const sourcePath = path.join(sourceDir, dirent.name); + const targetPath = path.join(targetDir, dirent.name); - if (dirent.isDirectory()) { - fs.mkdirSync(targetPath, { recursive: true }); - queue.push({ sourceDir: sourcePath, targetDir: targetPath }); - continue; - } - - if (dirent.isSymbolicLink()) { - fs.symlinkSync(fs.readlinkSync(sourcePath), targetPath); - continue; - } - - if (!dirent.isFile()) { - continue; - } - - linkOrCopyFile(sourcePath, targetPath); + if (dirent.isDirectory()) { + stagePluginRuntimeOverlay(sourcePath, targetPath); + continue; } - } -} -function symlinkType() { - return process.platform === "win32" ? "junction" : "dir"; + 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) { @@ -79,15 +108,17 @@ export function stageBundledPluginRuntime(params = {}) { } removePathIfExists(runtimeRoot); - mirrorTreeWithHardlinks(distRoot, 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, 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/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 9f3ab45379f..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; 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/plugins/loader.test.ts b/src/plugins/loader.test.ts index 325290cded2..d9fc2308412 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3211,6 +3211,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", @@ -3243,6 +3253,15 @@ module.exports = { expect(resolved).toBe(srcFile); }); + it("prefers dist plugin runtime module when loader runs from dist", () => { + const { root, distFile } = createPluginRuntimeAliasFixture(); + + 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", () => { const { root, srcFile } = createPluginRuntimeAliasFixture(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 3d6297f90d2..e86f846b5d8 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -198,6 +198,21 @@ const resolvePluginSdkAliasFile = (params: { const resolvePluginSdkAlias = (): string | null => resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" }); +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 { const modulePath = resolveLoaderModulePath(params); @@ -273,6 +288,7 @@ const resolvePluginSdkScopedAliasMap = (): Record => { }; export const __testing = { + buildPluginLoaderJitiOptions, listPluginSdkAliasCandidates, listPluginSdkExportedSubpaths, resolvePluginSdkAliasCandidateOrder, @@ -839,15 +855,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi ...(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; }; diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index f96a2408c6a..6d91ab90323 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -1,8 +1,11 @@ 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[] = []; @@ -19,7 +22,7 @@ afterEach(() => { }); describe("stageBundledPluginRuntime", () => { - it("hard-links bundled dist plugins into dist-runtime and links plugin-local node_modules", () => { + 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 }); @@ -39,14 +42,16 @@ describe("stageBundledPluginRuntime", () => { const runtimePluginDir = path.join(repoRoot, "dist-runtime", "extensions", "diffs"); expect(fs.existsSync(path.join(runtimePluginDir, "index.js"))).toBe(true); - expect(fs.statSync(path.join(runtimePluginDir, "index.js")).nlink).toBeGreaterThan(1); + 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("hard-links top-level dist chunks so staged bundled plugins keep relative imports working", () => { + 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( @@ -62,19 +67,138 @@ describe("stageBundledPluginRuntime", () => { stageBundledPluginRuntime({ repoRoot }); - const runtimeChunkPath = path.join(repoRoot, "dist-runtime", "chunk-abc.js"); - expect(fs.readFileSync(runtimeChunkPath, "utf8")).toContain("value = 1"); - expect(fs.statSync(runtimeChunkPath).nlink).toBeGreaterThan(1); - expect( - fs.readFileSync( - path.join(repoRoot, "dist-runtime", "extensions", "diffs", "index.js"), - "utf8", + 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("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, ), - ).toContain("../../chunk-abc.js"); - const distChunkStats = fs.statSync(path.join(repoRoot, "dist", "chunk-abc.js")); - const runtimeChunkStats = fs.statSync(runtimeChunkPath); - expect(runtimeChunkStats.ino).toBe(distChunkStats.ino); - expect(runtimeChunkStats.dev).toBe(distChunkStats.dev); + "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", () => { diff --git a/tsdown.config.ts b/tsdown.config.ts index 2bb86b24c21..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,6 +126,33 @@ 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", @@ -130,33 +171,34 @@ function buildCoreDistEntries(): Record { "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({ - // Build the root dist entrypoints together so they can share hashed chunks - // instead of emitting near-identical copies across separate builds. - entry: coreDistEntries, - }), - 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/hooks/bundled/*/handler.ts", "src/hooks/llm-slug-generator.ts"], - }), ]); From 750ce393bc4088af8d6a719135ce5cb6af75b655 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 08:38:57 -0700 Subject: [PATCH 004/128] Plugins: stabilize global catalog contracts --- .../contracts/catalog.contract.test.ts | 45 ++++++++++- src/plugins/contracts/wizard.contract.test.ts | 81 +++++++++---------- 2 files changed, 82 insertions(+), 44 deletions(-) diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts index 306162b2dcf..653f8448033 100644 --- a/src/plugins/contracts/catalog.contract.test.ts +++ b/src/plugins/contracts/catalog.contract.test.ts @@ -1,11 +1,50 @@ -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(); + +vi.mock("../providers.js", () => ({ + resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), + resolveOwningPluginIdsForProvider: (...args: unknown[]) => + resolveOwningPluginIdsForProviderMock(...args), +})); + +const { augmentModelCatalogWithProviderPlugins, buildProviderMissingAuthMessageWithPlugin, 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))]; + + resolveOwningPluginIdsForProviderMock.mockReset(); + resolveOwningPluginIdsForProviderMock.mockReturnValue(providerIds); + + 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/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)), From 0f013575f8e79116cc3ccf1aa3722ee77e0468d1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 08:39:17 -0700 Subject: [PATCH 005/128] Channels: add global threading and directory contracts --- .../contracts/directory.contract.test.ts | 12 + .../contracts/registry.contract.test.ts | 24 ++ src/channels/plugins/contracts/registry.ts | 28 ++ .../session-binding.contract.test.ts | 151 +++++++++++ src/channels/plugins/contracts/suites.ts | 250 ++++++++++++++++++ .../contracts/threading.contract.test.ts | 11 + 6 files changed, 476 insertions(+) create mode 100644 src/channels/plugins/contracts/directory.contract.test.ts create mode 100644 src/channels/plugins/contracts/session-binding.contract.test.ts create mode 100644 src/channels/plugins/contracts/threading.contract.test.ts 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 2d4569383f8..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(); @@ -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..90d852e7923 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -5,13 +5,21 @@ import type { ResolveProviderRuntimeGroupPolicyParams, RuntimeGroupPolicyResolution, } from "../../../config/runtime-group-policy.js"; +import type { + SessionBindingCapabilities, + SessionBindingRecord, +} from "../../../infra/outbound/session-binding-service.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 +31,68 @@ function sortStrings(values: readonly string[]) { return [...values].toSorted((left, right) => left.localeCompare(right)); } +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 +298,186 @@ 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", + }); + if (self) { + expectDirectoryEntryShape(self); + } + + const peers = + (await directory?.listPeers?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + query: "", + limit: 5, + })) ?? []; + 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, + })) ?? []; + 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, + query: "", + limit: 5, + }); + 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, + }); + }); +} From 02df22a49548952c8fc010e112ac97215f770818 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 08:40:06 -0700 Subject: [PATCH 006/128] Tests: improve extension runner discovery --- scripts/test-extension.mjs | 39 +++++++++++++++++++++++++++-- test/scripts/test-extension.test.ts | 11 ++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/scripts/test-extension.mjs b/scripts/test-extension.mjs index eb7eef78925..6442556c778 100644 --- a/scripts/test-extension.mjs +++ b/scripts/test-extension.mjs @@ -65,6 +65,20 @@ 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(); @@ -167,6 +181,7 @@ 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 ]", ); @@ -176,9 +191,15 @@ async function run() { const rawArgs = process.argv.slice(2); const dryRun = rawArgs.includes("--dry-run"); const json = rawArgs.includes("--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-changed", + (arg) => + arg !== "--" && + arg !== "--dry-run" && + arg !== "--json" && + arg !== "--list" && + arg !== "--list-changed", ); let base = ""; @@ -204,6 +225,18 @@ async function run() { 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 { @@ -239,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); } diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 63561cb5151..8919130c19a 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { detectChangedExtensionIds, + listAvailableExtensionIds, resolveExtensionTestPlan, } from "../../scripts/test-extension.mjs"; @@ -61,4 +62,14 @@ describe("scripts/test-extension.mjs", () => { 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)), + ); + }); }); From 8b2f0cbb6c2a632e3ef3df71d5b9477d5d82ad67 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 08:40:29 -0700 Subject: [PATCH 007/128] CI: run global contract lane --- .github/workflows/ci.yml | 3 +++ CONTRIBUTING.md | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80ea249e3d7..7266469c4a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b1fa35d6a3..d0327a8ad62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,9 @@ Welcome to the lobster tank! 🦞 - Run tests: `pnpm build && pnpm check && pnpm test` - For extension/plugin changes, run the fast local lane first: - `pnpm test:extension ` - - If you changed shared plugin or channel surfaces, still run the broader relevant lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review + - `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) From 4194bba575f7280d455743a5d26db2de9d01a2dc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 17:57:09 -0700 Subject: [PATCH 008/128] Plugins: speed up auth-choice contracts --- .../contracts/auth-choice.contract.test.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) 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(); From 0a93e22b37b5c9367c5ce37debf41fe8d50c9240 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 18:02:46 -0700 Subject: [PATCH 009/128] Plugins: fix catalog contract mocks --- src/plugins/contracts/catalog.contract.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts index 653f8448033..16a93d30dbe 100644 --- a/src/plugins/contracts/catalog.contract.test.ts +++ b/src/plugins/contracts/catalog.contract.test.ts @@ -11,16 +11,20 @@ function uniqueProviders() { 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, } = await import("../provider-runtime.js"); @@ -28,10 +32,14 @@ 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; From 6c1433a3c0ec2ea992b06c246f9daf6800293836 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 18:33:07 -0700 Subject: [PATCH 010/128] refactor: move provider catalogs into extensions --- extensions/byteplus/index.ts | 5 +- extensions/byteplus/provider-catalog.ts | 24 + extensions/huggingface/index.ts | 2 +- extensions/huggingface/provider-catalog.ts | 22 + extensions/kilocode/index.ts | 2 +- extensions/kilocode/provider-catalog.ts | 34 + extensions/kimi-coding/index.ts | 2 +- extensions/kimi-coding/provider-catalog.ts | 34 + extensions/minimax/index.ts | 5 +- extensions/minimax/provider-catalog.ts | 77 +++ extensions/modelstudio/index.ts | 2 +- extensions/modelstudio/provider-catalog.ts | 93 +++ extensions/moonshot/index.ts | 2 +- extensions/moonshot/provider-catalog.ts | 30 + extensions/nvidia/index.ts | 2 +- extensions/nvidia/provider-catalog.ts | 48 ++ extensions/ollama/index.ts | 2 +- extensions/openai/openai-codex-catalog.ts | 11 + extensions/openai/openai-codex-provider.ts | 2 +- extensions/openrouter/index.ts | 2 +- extensions/openrouter/provider-catalog.ts | 48 ++ extensions/qianfan/index.ts | 2 +- extensions/qianfan/provider-catalog.ts | 39 ++ extensions/qwen-portal-auth/index.ts | 35 +- .../qwen-portal-auth/provider-catalog.ts | 46 ++ extensions/synthetic/index.ts | 2 +- extensions/synthetic/provider-catalog.ts | 14 + extensions/together/index.ts | 2 +- extensions/together/provider-catalog.ts | 14 + extensions/venice/index.ts | 2 +- extensions/venice/provider-catalog.ts | 11 + extensions/vercel-ai-gateway/index.ts | 2 +- .../vercel-ai-gateway/provider-catalog.ts | 13 + extensions/volcengine/index.ts | 5 +- extensions/volcengine/provider-catalog.ts | 24 + extensions/xiaomi/index.ts | 2 +- extensions/xiaomi/provider-catalog.ts | 30 + .../models-config.providers.discovery.ts | 57 +- src/agents/models-config.providers.static.ts | 586 ++---------------- 39 files changed, 672 insertions(+), 663 deletions(-) create mode 100644 extensions/byteplus/provider-catalog.ts create mode 100644 extensions/huggingface/provider-catalog.ts create mode 100644 extensions/kilocode/provider-catalog.ts create mode 100644 extensions/kimi-coding/provider-catalog.ts create mode 100644 extensions/minimax/provider-catalog.ts create mode 100644 extensions/modelstudio/provider-catalog.ts create mode 100644 extensions/moonshot/provider-catalog.ts create mode 100644 extensions/nvidia/provider-catalog.ts create mode 100644 extensions/openai/openai-codex-catalog.ts create mode 100644 extensions/openrouter/provider-catalog.ts create mode 100644 extensions/qianfan/provider-catalog.ts create mode 100644 extensions/qwen-portal-auth/provider-catalog.ts create mode 100644 extensions/synthetic/provider-catalog.ts create mode 100644 extensions/together/provider-catalog.ts create mode 100644 extensions/venice/provider-catalog.ts create mode 100644 extensions/vercel-ai-gateway/provider-catalog.ts create mode 100644 extensions/volcengine/provider-catalog.ts create mode 100644 extensions/xiaomi/provider-catalog.ts 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/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/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/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 9f4e7eef1ea..5386a37d270 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -6,8 +6,8 @@ import { 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"; 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 999c37c6204..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 { 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/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/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/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/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/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/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/src/agents/models-config.providers.discovery.ts b/src/agents/models-config.providers.discovery.ts index 01dfb28e469..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, @@ -24,9 +16,11 @@ import { SELF_HOSTED_DEFAULT_MAX_TOKENS, } from "./self-hosted-provider-defaults.js"; import { SGLANG_DEFAULT_BASE_URL, SGLANG_PROVIDER_LABEL } from "./sglang-defaults.js"; -import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; -import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js"; import { VLLM_DEFAULT_BASE_URL, VLLM_PROVIDER_LABEL } from "./vllm-defaults.js"; +export { 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"; @@ -145,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 }, @@ -166,27 +151,6 @@ 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; @@ -220,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"; From e554eee541a8c5f752c81d354343c346c29af570 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 18:35:13 -0700 Subject: [PATCH 011/128] refactor: route bundled channel setup helpers through private sdk bridges --- extensions/discord/src/accounts.ts | 10 ++-- extensions/discord/src/draft-chunking.ts | 2 +- extensions/discord/src/setup-core.ts | 21 ++++---- extensions/discord/src/setup-surface.ts | 16 +++--- extensions/discord/src/token.ts | 2 +- extensions/imessage/src/accounts.ts | 11 ++-- extensions/imessage/src/probe.test.ts | 31 +++++------ extensions/imessage/src/setup-core.ts | 21 ++++---- extensions/imessage/src/setup-surface.ts | 18 ++++--- extensions/signal/src/accounts.ts | 11 ++-- extensions/signal/src/probe.test.ts | 17 ++---- extensions/signal/src/setup-core.ts | 25 ++++----- extensions/signal/src/setup-surface.ts | 22 ++++---- extensions/slack/src/accounts.ts | 15 +++--- extensions/slack/src/setup-core.ts | 25 +++++---- extensions/slack/src/setup-surface.ts | 17 +++--- extensions/telegram/src/accounts.test.ts | 12 +++-- extensions/telegram/src/accounts.ts | 13 +++-- extensions/telegram/src/draft-chunking.ts | 2 +- extensions/telegram/src/probe.ts | 2 +- extensions/telegram/src/setup-core.ts | 28 +++++----- extensions/telegram/src/setup-surface.ts | 14 ++--- extensions/telegram/src/token.ts | 4 +- extensions/whatsapp/src/accounts.ts | 19 ++++--- extensions/whatsapp/src/setup-core.ts | 6 +-- extensions/whatsapp/src/setup-surface.ts | 47 +++++++++++++---- src/plugin-sdk-internal/accounts.ts | 8 +++ src/plugin-sdk-internal/setup.ts | 37 +++++++++++++ src/plugin-sdk/index.ts | 64 ++++++++++++++++++++++- src/plugin-sdk/telegram.ts | 19 +++---- 30 files changed, 343 insertions(+), 196 deletions(-) create mode 100644 src/plugin-sdk-internal/accounts.ts create mode 100644 src/plugin-sdk-internal/setup.ts 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/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/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/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..5eb406cace8 100644 --- a/extensions/imessage/src/probe.test.ts +++ b/extensions/imessage/src/probe.test.ts @@ -1,36 +1,29 @@ 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, }); - 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/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/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/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/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/setup-core.ts b/extensions/slack/src/setup-core.ts index 6b32f206d2e..9b8ad30d240 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,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"; -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"; diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index 5769c4c6d77..6493a17ac79 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, diff --git a/extensions/telegram/src/accounts.test.ts b/extensions/telegram/src/accounts.test.ts index 28af65a5d8a..839e2f64008 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 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/draft-chunking.ts b/extensions/telegram/src/draft-chunking.ts index 951fbb41951..76edc1b1811 100644 --- a/extensions/telegram/src/draft-chunking.ts +++ b/extensions/telegram/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 { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index 8a12161470a..dfa7707f144 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -1,5 +1,5 @@ +import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/telegram"; import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; -import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js"; import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js"; import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; 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/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/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..21e1803263c 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 { + DEFAULT_ACCOUNT_ID, + type DmPolicy, + 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/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/index.ts b/src/plugin-sdk/index.ts index e43be3bfadd..721e9da25e6 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, @@ -222,6 +244,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 +393,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 +423,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 +461,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 +495,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 +662,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 +674,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 +716,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/telegram.ts b/src/plugin-sdk/telegram.ts index 2eed87097f0..3e1275c1425 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -4,24 +4,25 @@ export type { ChannelMessageActionAdapter, } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; -export type { TelegramAccountConfig, TelegramActionConfig } from "../config/types.js"; +export type { PluginRuntime } from "../plugins/runtime/types.js"; +export type { OpenClawPluginApi } from "../plugins/types.js"; export type { - ChannelMessageActionContext, - ChannelPlugin, - OpenClawPluginApi, - PluginRuntime, -} from "./channel-plugin-common.js"; + 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 { - DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, applyAccountNameToChannelSection, buildChannelConfigSchema, deleteAccountFromConfigSection, - emptyPluginConfigSchema, formatPairingApproveHint, getChatChannelMeta, migrateBaseNameToDefaultAccount, - normalizeAccountId, setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; From 8a10903cf7118fc685cc5d4885aa94fad70ee679 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 18:37:49 -0700 Subject: [PATCH 012/128] test: fix check contract type drift --- src/channels/plugins/contracts/suites.ts | 8 +++++++- src/plugins/bundle-mcp.test.ts | 18 ++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index 90d852e7923..cfd4e17eeff 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -9,6 +9,7 @@ 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"; @@ -31,6 +32,8 @@ 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"); @@ -404,6 +407,7 @@ export function installChannelDirectoryContractSuite(params: { const self = await directory?.self?.({ cfg: {} as OpenClawConfig, accountId: "default", + runtime: contractRuntime, }); if (self) { expectDirectoryEntryShape(self); @@ -415,6 +419,7 @@ export function installChannelDirectoryContractSuite(params: { accountId: "default", query: "", limit: 5, + runtime: contractRuntime, })) ?? []; expect(Array.isArray(peers)).toBe(true); for (const peer of peers) { @@ -427,6 +432,7 @@ export function installChannelDirectoryContractSuite(params: { accountId: "default", query: "", limit: 5, + runtime: contractRuntime, })) ?? []; expect(Array.isArray(groups)).toBe(true); for (const group of groups) { @@ -438,8 +444,8 @@ export function installChannelDirectoryContractSuite(params: { cfg: {} as OpenClawConfig, accountId: "default", groupId: groups[0].id, - query: "", limit: 5, + runtime: contractRuntime, }); expect(Array.isArray(members)).toBe(true); for (const member of members) { diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index 4285b64e660..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,13 +78,18 @@ describe("loadEnabledBundleMcpConfig", () => { cfg: config, }); const resolvedServerPath = await fs.realpath(serverPath); - const loadedServerPath = loaded.config.mcpServers.bundleProbe?.args?.[0]; + 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).toHaveLength(1); + expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node"); + expect(loadedArgs).toHaveLength(1); expect(loadedServerPath).toBeDefined(); - expect(await fs.realpath(loadedServerPath as string)).toBe(resolvedServerPath); + if (!loadedServerPath) { + throw new Error("expected bundled MCP args to include the server path"); + } + expect(await fs.realpath(loadedServerPath)).toBe(resolvedServerPath); } finally { env.restore(); } From 6805a80da24530d14aef11fd1f59727881160e71 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 18:30:30 -0700 Subject: [PATCH 013/128] Tests: lock plugin slash commands to one runtime graph --- .../native-command.plugin-dispatch.test.ts | 50 ++++++ .../src/bot-native-commands.registry.test.ts | 143 ++++++++++++++++++ .../stage-bundled-plugin-runtime.test.ts | 109 +++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 extensions/telegram/src/bot-native-commands.registry.test.ts 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..08f5d6151f1 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,54 @@ 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("executes matched plugin commands directly without invoking the agent dispatcher", async () => { const cfg = createConfig(); const commandSpec: NativeCommandSpec = { 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..5ebf92e1300 --- /dev/null +++ b/extensions/telegram/src/bot-native-commands.registry.test.ts @@ -0,0 +1,143 @@ +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; + }; + + 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 commandHandlers = new Map Promise>(); + const sendMessage = vi.fn().mockResolvedValue(undefined); + const setMyCommands = vi.fn().mockResolvedValue(undefined); + + 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: { + api: { + setMyCommands, + sendMessage, + }, + command: vi.fn((name: string, cb: (ctx: unknown) => Promise) => { + commandHandlers.set(name, cb); + }), + } as unknown as Parameters[0]["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."); + }); +}); diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index 6d91ab90323..419192b06fd 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -77,6 +77,115 @@ describe("stageBundledPluginRuntime", () => { 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"); From 7959be4336bd4517cb9bb5ca715e7d41f5f1853b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 18:36:34 -0700 Subject: [PATCH 014/128] Tests: cover Discord provider plugin registry --- .../src/monitor/provider.registry.test.ts | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 extensions/discord/src/monitor/provider.registry.test.ts 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); + }); +}); From 9c80d717bcb496ac1aebc49e6613bcdde599a33d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 18:40:50 -0700 Subject: [PATCH 015/128] Tests: pin loader command activation semantics --- src/plugins/loader.test.ts | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index d9fc2308412..808ba4c8cb7 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -713,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", From e88c6d848647907225a3acf3b8f350158cd09ccd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 18:43:05 -0700 Subject: [PATCH 016/128] Tests: cover Telegram plugin auth on real registry --- .../src/bot-native-commands.registry.test.ts | 98 ++++++++++++++++--- 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/extensions/telegram/src/bot-native-commands.registry.test.ts b/extensions/telegram/src/bot-native-commands.registry.test.ts index 5ebf92e1300..d264a059505 100644 --- a/extensions/telegram/src/bot-native-commands.registry.test.ts +++ b/extensions/telegram/src/bot-native-commands.registry.test.ts @@ -30,6 +30,22 @@ describe("registerTelegramNativeCommands real plugin registry", () => { 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 { @@ -88,9 +104,7 @@ describe("registerTelegramNativeCommands real plugin registry", () => { }); it("registers and executes plugin commands through the real plugin registry", async () => { - const commandHandlers = new Map Promise>(); - const sendMessage = vi.fn().mockResolvedValue(undefined); - const setMyCommands = vi.fn().mockResolvedValue(undefined); + const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot(); expect( registerPluginCommand("demo-plugin", { @@ -104,15 +118,7 @@ describe("registerTelegramNativeCommands real plugin registry", () => { registerTelegramNativeCommands({ ...buildParams({}), - bot: { - api: { - setMyCommands, - sendMessage, - }, - command: vi.fn((name: string, cb: (ctx: unknown) => Promise) => { - commandHandlers.set(name, cb); - }), - } as unknown as Parameters[0]["bot"], + bot, }); const registeredCommands = await waitForRegisteredCommands(setMyCommands); @@ -140,4 +146,72 @@ describe("registerTelegramNativeCommands real plugin registry", () => { ); 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(); + }); }); From 1c0db5b8e4314a9de3b69b409531859c1931d93a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 01:43:45 +0000 Subject: [PATCH 017/128] refactor(slack): share setup helpers --- extensions/slack/src/channel.setup.ts | 46 +-- extensions/slack/src/channel.ts | 41 +-- .../slack/src/message-action-dispatch.ts | 335 +----------------- extensions/slack/src/setup-core.ts | 118 +----- extensions/slack/src/setup-surface.ts | 118 +----- extensions/slack/src/shared.ts | 152 ++++++++ 6 files changed, 177 insertions(+), 633 deletions(-) create mode 100644 extensions/slack/src/shared.ts 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 fc902f49558..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/core"; -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/setup-core.ts b/extensions/slack/src/setup-core.ts index 9b8ad30d240..a0f068b3e81 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -22,92 +22,12 @@ import { } 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({ @@ -118,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 }) => @@ -256,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?", @@ -265,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 6493a17ac79..de7dc06e40e 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -30,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({ @@ -227,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: { @@ -254,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?", @@ -263,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"], +}); From b230e524a52a6fd115e51d57d0b2ef56d75b7273 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 01:43:49 +0000 Subject: [PATCH 018/128] refactor(whatsapp): reuse shared normalize helpers --- extensions/whatsapp/src/normalize.ts | 33 +++++----------------------- 1 file changed, 5 insertions(+), 28 deletions(-) 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"; From 029f5d642729052d15a0767f394d78b4a248bef7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 18:43:35 -0700 Subject: [PATCH 019/128] Tlon: lazy-load channel runtime paths --- extensions/tlon/src/channel.runtime.ts | 249 +++++++++++++++++ extensions/tlon/src/channel.ts | 358 ++++++++----------------- 2 files changed, 368 insertions(+), 239 deletions(-) create mode 100644 extensions/tlon/src/channel.runtime.ts diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts new file mode 100644 index 00000000000..12427fb23b0 --- /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..2c330c16c4a 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), }, }; From ad05cd9ab2fa94128566a8c28dd5d076bad45575 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 18:45:15 -0700 Subject: [PATCH 020/128] Tests: document Discord plugin auth gating --- .../native-command.plugin-dispatch.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) 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 08f5d6151f1..97401cec0d8 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -212,6 +212,79 @@ describe("Discord native plugin command dispatch", () => { ); }); + 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 = { From 662031a88e5eac1f31eeaf87293241204e6645ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 18:49:55 -0700 Subject: [PATCH 021/128] feat(plugins): add speech provider registration --- extensions/elevenlabs/index.ts | 14 + extensions/elevenlabs/openclaw.plugin.json | 8 + extensions/elevenlabs/package.json | 12 + extensions/lobster/src/lobster-tool.test.ts | 1 + extensions/microsoft/index.ts | 14 + extensions/microsoft/openclaw.plugin.json | 8 + extensions/microsoft/package.json | 12 + extensions/openai/index.ts | 2 + extensions/test-utils/plugin-api.ts | 1 + extensions/voice-call/index.ts | 2 +- extensions/voice-call/openclaw.plugin.json | 5 +- src/auto-reply/reply/commands-tts.ts | 19 +- src/auto-reply/reply/route-reply.test.ts | 1 + .../channel-setup/plugin-install.test.ts | 1 + src/config/types.tts.ts | 19 +- src/config/zod-schema.core.ts | 34 ++- src/gateway/server-methods/tts.ts | 52 ++-- src/gateway/server-plugins.test.ts | 1 + ...server.agent.gateway-server-agent.mocks.ts | 20 +- src/gateway/test-helpers.mocks.ts | 1 + src/plugin-sdk/core.ts | 1 + src/plugin-sdk/index.ts | 1 + src/plugins/loader.ts | 1 + src/plugins/registry.ts | 48 +++ src/plugins/types.ts | 26 ++ src/test-utils/channel-plugins.ts | 1 + src/test-utils/plugin-registration.ts | 7 + src/tts/provider-registry.ts | 84 ++++++ src/tts/provider-types.ts | 38 +++ src/tts/providers/elevenlabs.ts | 73 +++++ src/tts/providers/microsoft.ts | 60 ++++ src/tts/providers/openai.ts | 56 ++++ src/tts/tts-core.ts | 11 +- src/tts/tts.test.ts | 25 +- src/tts/tts.ts | 285 +++++------------- 35 files changed, 658 insertions(+), 286 deletions(-) create mode 100644 extensions/elevenlabs/index.ts create mode 100644 extensions/elevenlabs/openclaw.plugin.json create mode 100644 extensions/elevenlabs/package.json create mode 100644 extensions/microsoft/index.ts create mode 100644 extensions/microsoft/openclaw.plugin.json create mode 100644 extensions/microsoft/package.json create mode 100644 src/tts/provider-registry.ts create mode 100644 src/tts/provider-types.ts create mode 100644 src/tts/providers/elevenlabs.ts create mode 100644 src/tts/providers/microsoft.ts create mode 100644 src/tts/providers/openai.ts 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/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/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/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/voice-call/index.ts b/extensions/voice-call/index.ts index 7d14270bcf8..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 }, 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/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/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/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/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/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-plugins.test.ts b/src/gateway/server-plugins.test.ts index 8e0d97a1580..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: [], 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/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 4bfb7ef4e4d..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: [], diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 0c521f84122..00621521067 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -21,6 +21,7 @@ export type { ProviderResolveDynamicModelContext, ProviderNormalizeResolvedModelContext, ProviderRuntimeModel, + SpeechProviderPlugin, ProviderThinkingPolicyContext, ProviderWrapStreamFnContext, OpenClawPluginService, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 721e9da25e6..07b51661d2d 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -140,6 +140,7 @@ export type { ProviderResolveDynamicModelContext, ProviderNormalizeResolvedModelContext, ProviderRuntimeModel, + SpeechProviderPlugin, ProviderThinkingPolicyContext, ProviderWrapStreamFnContext, } from "../plugins/types.js"; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index e86f846b5d8..a2e05fc06b9 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -494,6 +494,7 @@ function createPluginRecord(params: { hookNames: [], channelIds: [], providerIds: [], + speechProviderIds: [], webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], 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/types.ts b/src/plugins/types.ts index 0c817a99cf8..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"; @@ -853,6 +861,23 @@ 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; @@ -1211,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/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)); From 6da9ba3267b96685150adad46e403b3c2505400e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 18:50:03 -0700 Subject: [PATCH 022/128] docs(plugins): document capability ownership model --- docs/plugins/voice-call.md | 4 +- docs/tools/plugin.md | 198 +++++++++++++++++++++++++++++++- docs/tts.md | 75 ++++++------ extensions/voice-call/README.md | 2 +- 4 files changed, 238 insertions(+), 41 deletions(-) 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/tools/plugin.md b/docs/tools/plugin.md index ec0247c8d72..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: @@ -1110,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 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/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` From 76500c7a785e1491de2b4634fbc76cc66e0959bb Mon Sep 17 00:00:00 2001 From: lishuaigit Date: Tue, 17 Mar 2026 09:57:33 +0800 Subject: [PATCH 023/128] fix: detect Ollama "prompt too long" as context overflow error (#34019) Merged via squash. Prepared head SHA: 825a402f0fe7d521902291e7dd6dbb288699224e Co-authored-by: lishuaigit <7495165+lishuaigit@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 4 + extensions/imessage/src/probe.test.ts | 1 + extensions/telegram/src/accounts.test.ts | 2 +- extensions/telegram/src/channel.setup.ts | 2 +- extensions/telegram/src/channel.ts | 2 +- extensions/tlon/src/channel.runtime.ts | 2 +- extensions/tlon/src/channel.ts | 4 +- extensions/whatsapp/src/setup-surface.ts | 4 +- ...d-helpers.formatassistanterrortext.test.ts | 6 + ...ded-helpers.sanitizeuserfacingtext.test.ts | 8 ++ src/agents/pi-embedded-helpers/errors.ts | 6 +- src/channels/plugins/contracts/suites.ts | 1 - src/infra/provider-usage.auth.ts | 91 ++++++++++++++ src/infra/provider-usage.load.ts | 111 +++++++++++++++++- src/infra/secret-file.ts | 19 ++- src/infra/warning-filter.test.ts | 5 +- src/infra/warning-filter.ts | 14 +++ src/plugin-sdk/index.test.ts | 25 ++-- src/plugins/provider-runtime.ts | 14 ++- .../stage-bundled-plugin-runtime.test.ts | 4 +- 20 files changed, 275 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3534d41f0e6..4192bba536a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -274,6 +274,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/extensions/imessage/src/probe.test.ts b/extensions/imessage/src/probe.test.ts index 5eb406cace8..ef69337984b 100644 --- a/extensions/imessage/src/probe.test.ts +++ b/extensions/imessage/src/probe.test.ts @@ -13,6 +13,7 @@ beforeEach(() => { code: 1, signal: null, killed: false, + termination: "exit", }); }); diff --git a/extensions/telegram/src/accounts.test.ts b/extensions/telegram/src/accounts.test.ts index 839e2f64008..fb83b9071a5 100644 --- a/extensions/telegram/src/accounts.test.ts +++ b/extensions/telegram/src/accounts.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { warn: warnMock, child: () => logger, }; - return logger as ReturnType; + return logger as unknown as ReturnType; }); }); 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.ts b/extensions/telegram/src/channel.ts index 5b3ce7279c6..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"; diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts index 12427fb23b0..f9f11e4620c 100644 --- a/extensions/tlon/src/channel.runtime.ts +++ b/extensions/tlon/src/channel.runtime.ts @@ -230,7 +230,7 @@ export async function probeTlonAccount(account: ConfiguredTlonAccount) { } export async function startTlonGatewayAccount( - ctx: Parameters["startAccount"]>[0], + ctx: Parameters["startAccount"]>>[0], ) { const account = ctx.account; ctx.setStatus({ diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 2c330c16c4a..4442279a727 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -27,12 +27,12 @@ const tlonSetupWizardProxy = { resolveConfigured: async ({ cfg }) => await (await loadTlonChannelRuntime()).tlonSetupWizard.status.resolveConfigured({ cfg }), resolveStatusLines: async ({ cfg, configured }) => - await ( + (await ( await loadTlonChannelRuntime() ).tlonSetupWizard.status.resolveStatusLines?.({ cfg, configured, - }), + })) ?? [], }, introNote: { title: "Tlon setup", diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 21e1803263c..e2ec4149631 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -1,7 +1,7 @@ import path from "node:path"; +import type { DmPolicy } from "openclaw/plugin-sdk/whatsapp"; import { DEFAULT_ACCOUNT_ID, - type DmPolicy, formatCliCommand, formatDocsLink, normalizeAccountId, @@ -21,7 +21,7 @@ const channel = "whatsapp" as const; function mergeWhatsAppConfig( cfg: OpenClawConfig, - patch: Partial["whatsapp"]>, + patch: Partial["whatsapp"]>>, options?: { unsetOnUndefined?: string[] }, ): OpenClawConfig { const base = { ...(cfg.channels?.whatsapp ?? {}) } as Record; 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/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index cfd4e17eeff..a45abc3ff0b 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -33,7 +33,6 @@ function sortStrings(values: readonly string[]) { } const contractRuntime = createNonExitingRuntime(); - function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) { expect(["user", "group", "channel"]).toContain(entry.kind); expect(typeof entry.id).toBe("string"); 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/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/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/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 61a2a0c5792..561154196f0 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -10,6 +10,7 @@ import { resolveOwningPluginIdsForProvider, resolvePluginProviders, } from "./providers.js"; +import { resolvePluginCacheInputs } from "./roots.js"; import type { ProviderAuthDoctorHintContext, ProviderAugmentModelCatalogContext, @@ -76,8 +77,16 @@ function resolveHookProviderCacheBucket(params: { return bucket; } -function buildHookProviderCacheKey(params: { workspaceDir?: string; onlyPluginIds?: string[] }) { - return `${params.workspaceDir ?? ""}::${JSON.stringify(params.onlyPluginIds ?? [])}`; +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 { @@ -105,6 +114,7 @@ function resolveProviderPluginsForHooks(params: { const cacheKey = buildHookProviderCacheKey({ workspaceDir: params.workspaceDir, onlyPluginIds: params.onlyPluginIds, + env, }); const cached = cacheBucket.get(cacheKey); if (cached) { diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index 419192b06fd..fe246e8fcfe 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -153,9 +153,7 @@ describe("stageBundledPluginRuntime", () => { description: string; acceptsArgs: boolean; }>; - matchPluginCommand: ( - commandBody: string, - ) => { + matchPluginCommand: (commandBody: string) => { command: { handler: ({ args }: { args?: string }) => Promise<{ text: string }> }; args?: string; } | null; From 3aa4199ef0987b5b09d076104573fecc7eadeedc Mon Sep 17 00:00:00 2001 From: Keshav Rao Date: Mon, 16 Mar 2026 19:04:00 -0700 Subject: [PATCH 024/128] agent: preemptive context overflow detection during tool loops (#29371) Merged via squash. Prepared head SHA: 19661b8fb1e3aea20e438b28e8323d7f42fe01d6 Co-authored-by: keshav55 <3821985+keshav55@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 2 + extensions/telegram/src/bot/helpers.test.ts | 54 ++++++++++++++++- .../tool-result-context-guard.test.ts | 60 +++++++++++++++++++ .../tool-result-context-guard.ts | 22 +++++++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4192bba536a..d948e2b59ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,8 @@ Docs: https://docs.openclaw.ai - 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 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/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; From 7c2c20a62fa8898d97be57c27cd3109957158d87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 18:49:04 -0700 Subject: [PATCH 025/128] refactor: untangle bundled channel sdk bridges --- extensions/discord/src/account-inspect.ts | 10 +-- extensions/discord/src/accounts.ts | 10 +-- extensions/discord/src/channel.setup.ts | 42 +-------- extensions/discord/src/channel.ts | 42 ++------- extensions/discord/src/plugin-shared.ts | 39 +++++++++ extensions/discord/src/runtime.ts | 6 +- extensions/discord/src/subagent-hooks.ts | 2 +- extensions/imessage/src/accounts.ts | 2 +- extensions/imessage/src/channel.setup.ts | 15 +--- extensions/imessage/src/channel.ts | 19 ++--- extensions/imessage/src/plugin-shared.ts | 11 +++ extensions/imessage/src/runtime.ts | 6 +- .../imessage/src/target-parsing-helpers.ts | 2 +- extensions/signal/src/accounts.ts | 2 +- extensions/signal/src/channel.setup.ts | 28 +----- extensions/signal/src/channel.ts | 36 ++------ extensions/signal/src/identity.ts | 2 +- extensions/signal/src/plugin-shared.ts | 25 ++++++ extensions/signal/src/runtime.ts | 6 +- extensions/slack/src/account-inspect.ts | 10 +-- extensions/slack/src/accounts.ts | 2 +- extensions/slack/src/channel.setup.ts | 19 ++--- extensions/slack/src/channel.ts | 27 +++--- .../slack/src/message-action-dispatch.ts | 2 +- extensions/slack/src/plugin-shared.ts | 53 ++++++++++++ extensions/slack/src/runtime.ts | 6 +- extensions/telegram/src/account-inspect.ts | 4 +- extensions/telegram/src/accounts.ts | 7 +- extensions/telegram/src/channel-actions.ts | 3 +- extensions/telegram/src/channel.setup.ts | 74 ++-------------- extensions/telegram/src/channel.ts | 85 ++++--------------- extensions/telegram/src/group-access.ts | 2 +- extensions/telegram/src/plugin-shared.ts | 68 +++++++++++++++ extensions/telegram/src/probe.ts | 2 +- extensions/telegram/src/runtime.ts | 6 +- extensions/telegram/src/token.ts | 2 +- extensions/whatsapp/src/accounts.ts | 6 +- extensions/whatsapp/src/channel.setup.ts | 52 +----------- extensions/whatsapp/src/channel.ts | 54 +----------- extensions/whatsapp/src/plugin-shared.ts | 51 +++++++++++ extensions/whatsapp/src/runtime.ts | 6 +- extensions/whatsapp/src/setup-surface.ts | 1 + src/channels/plugins/contracts/suites.ts | 1 - src/plugin-sdk-internal/accounts.ts | 4 + src/plugin-sdk-internal/channel-config.ts | 17 ++++ src/plugin-sdk-internal/core.ts | 14 +++ src/plugin-sdk-internal/imessage.ts | 1 + src/plugin-sdk-internal/signal.ts | 2 + src/plugin-sdk-internal/telegram.ts | 9 +- 49 files changed, 439 insertions(+), 456 deletions(-) create mode 100644 extensions/discord/src/plugin-shared.ts create mode 100644 extensions/imessage/src/plugin-shared.ts create mode 100644 extensions/signal/src/plugin-shared.ts create mode 100644 extensions/slack/src/plugin-shared.ts create mode 100644 extensions/telegram/src/plugin-shared.ts create mode 100644 extensions/whatsapp/src/plugin-shared.ts create mode 100644 src/plugin-sdk-internal/channel-config.ts create mode 100644 src/plugin-sdk-internal/core.ts diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index bddea792c14..a998c5ba874 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -1,13 +1,13 @@ +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig, type DiscordAccountConfig, -} from "openclaw/plugin-sdk/discord"; -import { - hasConfiguredSecretInput, - normalizeSecretInputString, -} from "../../../src/config/types.secrets.js"; +} from "../../../src/plugin-sdk-internal/discord.js"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId, diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index 6e9d58c97de..39903077aaf 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -1,14 +1,14 @@ -import type { - OpenClawConfig, - DiscordAccountConfig, - DiscordActionConfig, -} from "openclaw/plugin-sdk/discord"; import { createAccountActionGate, createAccountListHelpers, normalizeAccountId, resolveAccountEntry, } from "../../../src/plugin-sdk-internal/accounts.js"; +import type { + OpenClawConfig, + DiscordAccountConfig, + DiscordActionConfig, +} from "../../../src/plugin-sdk-internal/discord.js"; import { resolveDiscordToken } from "./token.js"; export type ResolvedDiscordAccount = { diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts index ee157e3c9bb..3d1e9d30ba5 100644 --- a/extensions/discord/src/channel.setup.ts +++ b/extensions/discord/src/channel.setup.ts @@ -1,46 +1,12 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; -import { - createScopedAccountConfigAccessors, - formatAllowFromLowercase, -} from "openclaw/plugin-sdk/compat"; import { buildChannelConfigSchema, DiscordConfigSchema, getChatChannelMeta, type ChannelPlugin, -} from "openclaw/plugin-sdk/discord"; -import { inspectDiscordAccount } from "./account-inspect.js"; -import { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, - type ResolvedDiscordAccount, -} from "./accounts.js"; -import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; - -async function loadDiscordChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const discordConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, -}); - -const discordConfigBase = createScopedChannelConfigBase({ - sectionKey: "discord", - listAccountIds: listDiscordAccountIds, - resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultDiscordAccountId, - clearBaseFields: ["token", "name"], -}); - -const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ - discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, -})); +} from "../../../src/plugin-sdk-internal/discord.js"; +import { type ResolvedDiscordAccount } from "./accounts.js"; +import { discordConfigAccessors, discordConfigBase, discordSetupWizard } from "./plugin-shared.js"; +import { discordSetupAdapter } from "./setup-core.js"; export const discordSetupPlugin: ChannelPlugin = { id: "discord", diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 966a5a1cbcd..7b70feabbcd 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,18 +1,16 @@ import { Separator, TextDisplay } from "@buape/carbon"; -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, - collectOpenProviderGroupPolicyWarnings, collectOpenGroupPolicyConfiguredRouteWarnings, - createScopedAccountConfigAccessors, - formatAllowFromLowercase, -} from "openclaw/plugin-sdk/compat"; + collectOpenProviderGroupPolicyWarnings, +} from "../../../src/plugin-sdk-internal/channel-config.js"; import { buildAgentSessionKey, resolveThreadSessionKeys, type RoutePeer, -} from "openclaw/plugin-sdk/core"; +} from "../../../src/plugin-sdk-internal/core.js"; import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, @@ -30,14 +28,11 @@ import { type ChannelMessageActionAdapter, type ChannelPlugin, type OpenClawConfig, -} from "openclaw/plugin-sdk/discord"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +} from "../../../src/plugin-sdk-internal/discord.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; -import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, resolveDiscordAccount, - resolveDefaultDiscordAccountId, type ResolvedDiscordAccount, } from "./accounts.js"; import { collectDiscordAuditChannelIds } from "./audit.js"; @@ -50,11 +45,12 @@ import { normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, } from "./normalize.js"; +import { discordConfigAccessors, discordConfigBase, discordSetupWizard } from "./plugin-shared.js"; import type { DiscordProbe } from "./probe.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { getDiscordRuntime } from "./runtime.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; -import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; +import { discordSetupAdapter } from "./setup-core.js"; import { collectDiscordStatusIssues } from "./status-issues.js"; import { parseDiscordTarget } from "./targets.js"; import { DiscordUiContainer } from "./ui.js"; @@ -66,10 +62,6 @@ type DiscordSendFn = ReturnType< const meta = getChatChannelMeta("discord"); const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; -async function loadDiscordChannelRuntime() { - return await import("./channel.runtime.js"); -} - function formatDiscordIntents(intents?: { messageContent?: string; guildMembers?: string; @@ -304,26 +296,6 @@ function resolveDiscordOutboundSessionRoute(params: { }; } -const discordConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, -}); - -const discordConfigBase = createScopedChannelConfigBase({ - sectionKey: "discord", - listAccountIds: listDiscordAccountIds, - resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultDiscordAccountId, - clearBaseFields: ["token", "name"], -}); - -const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ - discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, -})); - export const discordPlugin: ChannelPlugin = { id: "discord", meta: { diff --git a/extensions/discord/src/plugin-shared.ts b/extensions/discord/src/plugin-shared.ts new file mode 100644 index 00000000000..9b5aec43b9e --- /dev/null +++ b/extensions/discord/src/plugin-shared.ts @@ -0,0 +1,39 @@ +import { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + formatAllowFromLowercase, +} from "../../../src/plugin-sdk-internal/channel-config.js"; +import { type OpenClawConfig } from "../../../src/plugin-sdk-internal/discord.js"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, + type ResolvedDiscordAccount, +} from "./accounts.js"; +import { createDiscordSetupWizardProxy } from "./setup-core.js"; + +async function loadDiscordChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const discordConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveDiscordAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, +}); + +export const discordConfigBase = createScopedChannelConfigBase({ + sectionKey: "discord", + listAccountIds: listDiscordAccountIds, + resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultDiscordAccountId, + clearBaseFields: ["token", "name"], +}); + +export const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ + discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, +})); diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 2dc10a295fd..b73ec43a065 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -1,5 +1,7 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + createPluginRuntimeStore, + type PluginRuntime, +} from "../../../src/plugin-sdk-internal/core.js"; const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } = createPluginRuntimeStore("Discord runtime not initialized"); diff --git a/extensions/discord/src/subagent-hooks.ts b/extensions/discord/src/subagent-hooks.ts index c9ba7b97984..fa45eadd7c2 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/core"; +import type { OpenClawPluginApi } from "../../../src/plugin-sdk-internal/core.js"; import { resolveDiscordAccount } from "./accounts.js"; import { autoBindSpawnedDiscordSubagent, diff --git a/extensions/imessage/src/accounts.ts b/extensions/imessage/src/accounts.ts index 21c3c36d356..67ffb5e6865 100644 --- a/extensions/imessage/src/accounts.ts +++ b/extensions/imessage/src/accounts.ts @@ -1,10 +1,10 @@ -import type { IMessageAccountConfig } from "openclaw/plugin-sdk/imessage"; import { type OpenClawConfig, createAccountListHelpers, normalizeAccountId, resolveAccountEntry, } from "../../../src/plugin-sdk-internal/accounts.js"; +import type { IMessageAccountConfig } from "../../../src/plugin-sdk-internal/imessage.js"; export type ResolvedIMessageAccount = { accountId: string; diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts index a4e58844b3b..16d758931c2 100644 --- a/extensions/imessage/src/channel.setup.ts +++ b/extensions/imessage/src/channel.setup.ts @@ -1,7 +1,7 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/compat"; +} from "../../../src/plugin-sdk-internal/channel-config.js"; import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, @@ -13,22 +13,15 @@ import { resolveIMessageConfigDefaultTo, setAccountEnabledInConfigSection, type ChannelPlugin, -} from "openclaw/plugin-sdk/imessage"; +} from "../../../src/plugin-sdk-internal/imessage.js"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, resolveIMessageAccount, type ResolvedIMessageAccount, } from "./accounts.js"; -import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js"; - -async function loadIMessageChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ - imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, -})); +import { imessageSetupWizard } from "./plugin-shared.js"; +import { imessageSetupAdapter } from "./setup-core.js"; export const imessageSetupPlugin: ChannelPlugin = { id: "imessage", diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index b0d94a1a437..18ae103281a 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,9 +1,10 @@ +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/compat"; -import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core"; +} from "../../../src/plugin-sdk-internal/channel-config.js"; +import { buildAgentSessionKey, type RoutePeer } from "../../../src/plugin-sdk-internal/core.js"; import { buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -22,8 +23,7 @@ import { resolveIMessageGroupToolPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, -} from "openclaw/plugin-sdk/imessage"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +} from "../../../src/plugin-sdk-internal/imessage.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listIMessageAccountIds, @@ -31,20 +31,13 @@ import { resolveIMessageAccount, type ResolvedIMessageAccount, } from "./accounts.js"; +import { imessageSetupWizard } from "./plugin-shared.js"; import { getIMessageRuntime } from "./runtime.js"; -import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js"; +import { imessageSetupAdapter } from "./setup-core.js"; import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; const meta = getChatChannelMeta("imessage"); -async function loadIMessageChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ - imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, -})); - type IMessageSendFn = ReturnType< typeof getIMessageRuntime >["channel"]["imessage"]["sendMessageIMessage"]; diff --git a/extensions/imessage/src/plugin-shared.ts b/extensions/imessage/src/plugin-shared.ts new file mode 100644 index 00000000000..c7ed39cd21a --- /dev/null +++ b/extensions/imessage/src/plugin-shared.ts @@ -0,0 +1,11 @@ +import { type ChannelPlugin } from "../../../src/plugin-sdk-internal/imessage.js"; +import { type ResolvedIMessageAccount } from "./accounts.js"; +import { createIMessageSetupWizardProxy } from "./setup-core.js"; + +async function loadIMessageChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ + imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, +})) satisfies NonNullable["setupWizard"]>; diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts index 08c9b6ccbbd..3a49020348f 100644 --- a/extensions/imessage/src/runtime.ts +++ b/extensions/imessage/src/runtime.ts @@ -1,5 +1,7 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + createPluginRuntimeStore, + type PluginRuntime, +} from "../../../src/plugin-sdk-internal/core.js"; const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } = createPluginRuntimeStore("iMessage runtime not initialized"); diff --git a/extensions/imessage/src/target-parsing-helpers.ts b/extensions/imessage/src/target-parsing-helpers.ts index 95ccc3682ce..7995b271fe4 100644 --- a/extensions/imessage/src/target-parsing-helpers.ts +++ b/extensions/imessage/src/target-parsing-helpers.ts @@ -1,4 +1,4 @@ -import { isAllowedParsedChatSender } from "../../../src/plugin-sdk/allow-from.js"; +import { isAllowedParsedChatSender } from "../../../src/plugin-sdk-internal/imessage.js"; export type ServicePrefix = { prefix: string; service: TService }; diff --git a/extensions/signal/src/accounts.ts b/extensions/signal/src/accounts.ts index 0bf9db0e79a..30a3b56189c 100644 --- a/extensions/signal/src/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -1,10 +1,10 @@ -import type { SignalAccountConfig } from "openclaw/plugin-sdk/signal"; import { type OpenClawConfig, createAccountListHelpers, normalizeAccountId, resolveAccountEntry, } from "../../../src/plugin-sdk-internal/accounts.js"; +import type { SignalAccountConfig } from "../../../src/plugin-sdk-internal/signal.js"; export type ResolvedSignalAccount = { accountId: string; diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index 88a7035c199..bc590cb235e 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -1,8 +1,7 @@ import { - createScopedAccountConfigAccessors, buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/compat"; +} from "../../../src/plugin-sdk-internal/channel-config.js"; import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, @@ -12,34 +11,15 @@ import { setAccountEnabledInConfigSection, SignalConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/signal"; +} from "../../../src/plugin-sdk-internal/signal.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, resolveSignalAccount, type ResolvedSignalAccount, } from "./accounts.js"; -import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; - -async function loadSignalChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ - signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, -})); - -const signalConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) - .filter(Boolean), - resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, -}); +import { signalConfigAccessors, signalSetupWizard } from "./plugin-shared.js"; +import { signalSetupAdapter } from "./setup-core.js"; export const signalSetupPlugin: ChannelPlugin = { id: "signal", diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index e1675a019d1..b0115d85a91 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,10 +1,12 @@ +import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, - createScopedAccountConfigAccessors, collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/compat"; -import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core"; +} from "../../../src/plugin-sdk-internal/channel-config.js"; +import { buildAgentSessionKey, type RoutePeer } from "../../../src/plugin-sdk-internal/core.js"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, @@ -23,10 +25,7 @@ import { SignalConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, -} from "openclaw/plugin-sdk/signal"; -import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; -import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +} from "../../../src/plugin-sdk-internal/signal.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, @@ -40,17 +39,10 @@ import { resolveSignalRecipient, resolveSignalSender, } from "./identity.js"; +import { signalConfigAccessors, signalSetupWizard } from "./plugin-shared.js"; import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; -import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; - -async function loadSignalChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ - signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, -})); +import { signalSetupAdapter } from "./setup-core.js"; const signalMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [], @@ -65,18 +57,6 @@ const signalMessageActions: ChannelMessageActionAdapter = { }, }; -const signalConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) - .filter(Boolean), - resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, -}); - type SignalSendFn = ReturnType["channel"]["signal"]["sendMessageSignal"]; function resolveSignalSendContext(params: { diff --git a/extensions/signal/src/identity.ts b/extensions/signal/src/identity.ts index c39b0dd5eaa..464713559c3 100644 --- a/extensions/signal/src/identity.ts +++ b/extensions/signal/src/identity.ts @@ -1,4 +1,4 @@ -import { evaluateSenderGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js"; +import { evaluateSenderGroupAccessForPolicy } from "../../../src/plugin-sdk-internal/signal.js"; import { normalizeE164 } from "../../../src/utils.js"; export type SignalSender = diff --git a/extensions/signal/src/plugin-shared.ts b/extensions/signal/src/plugin-shared.ts new file mode 100644 index 00000000000..60559f09dcb --- /dev/null +++ b/extensions/signal/src/plugin-shared.ts @@ -0,0 +1,25 @@ +import { createScopedAccountConfigAccessors } from "../../../src/plugin-sdk-internal/channel-config.js"; +import { normalizeE164, type OpenClawConfig } from "../../../src/plugin-sdk-internal/signal.js"; +import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; +import { createSignalSetupWizardProxy } from "./setup-core.js"; + +async function loadSignalChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ + signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, +})); + +export const signalConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveSignalAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) + .filter(Boolean), + resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, +}); diff --git a/extensions/signal/src/runtime.ts b/extensions/signal/src/runtime.ts index b7cc4160f1c..99bdf04a447 100644 --- a/extensions/signal/src/runtime.ts +++ b/extensions/signal/src/runtime.ts @@ -1,5 +1,7 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + createPluginRuntimeStore, + type PluginRuntime, +} from "../../../src/plugin-sdk-internal/core.js"; const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } = createPluginRuntimeStore("Signal runtime not initialized"); diff --git a/extensions/slack/src/account-inspect.ts b/extensions/slack/src/account-inspect.ts index 8ada00e9832..1cc3f2b8509 100644 --- a/extensions/slack/src/account-inspect.ts +++ b/extensions/slack/src/account-inspect.ts @@ -1,13 +1,13 @@ +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig, type SlackAccountConfig, -} from "openclaw/plugin-sdk/slack"; -import { - hasConfiguredSecretInput, - normalizeSecretInputString, -} from "../../../src/config/types.secrets.js"; +} from "../../../src/plugin-sdk-internal/slack.js"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { mergeSlackAccountConfig, diff --git a/extensions/slack/src/accounts.ts b/extensions/slack/src/accounts.ts index 51faf8a4a6b..4297e74902b 100644 --- a/extensions/slack/src/accounts.ts +++ b/extensions/slack/src/accounts.ts @@ -1,4 +1,3 @@ -import type { SlackAccountConfig } from "openclaw/plugin-sdk/slack"; import { type OpenClawConfig, createAccountListHelpers, @@ -7,6 +6,7 @@ import { normalizeChatType, resolveAccountEntry, } from "../../../src/plugin-sdk-internal/accounts.js"; +import type { SlackAccountConfig } from "../../../src/plugin-sdk-internal/slack.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 c221cc9cebf..f523e2a4d71 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -3,19 +3,16 @@ import { getChatChannelMeta, SlackConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/slack"; +} from "../../../src/plugin-sdk-internal/slack.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"); -} - -const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ - slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, -})); +import { + isSlackPluginAccountConfigured, + slackConfigAccessors, + slackConfigBase, + slackSetupWizard, +} from "./plugin-shared.js"; +import { slackSetupAdapter } from "./setup-core.js"; export const slackSetupPlugin: ChannelPlugin = { id: "slack", diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 4a43055c142..8005a29f76f 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,14 +1,15 @@ +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, - collectOpenProviderGroupPolicyWarnings, collectOpenGroupPolicyConfiguredRouteWarnings, -} from "openclaw/plugin-sdk/compat"; + collectOpenProviderGroupPolicyWarnings, +} from "../../../src/plugin-sdk-internal/channel-config.js"; import { buildAgentSessionKey, resolveThreadSessionKeys, type RoutePeer, -} from "openclaw/plugin-sdk/core"; +} from "../../../src/plugin-sdk-internal/core.js"; import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, @@ -26,8 +27,7 @@ import { SlackConfigSchema, type ChannelPlugin, type OpenClawConfig, -} from "openclaw/plugin-sdk/slack"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +} from "../../../src/plugin-sdk-internal/slack.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listEnabledSlackAccounts, @@ -41,22 +41,23 @@ import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { handleSlackMessageAction } from "./message-action-dispatch.js"; import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; +import { + isSlackPluginAccountConfigured, + slackConfigAccessors, + slackConfigBase, + slackSetupWizard, +} from "./plugin-shared.js"; import type { SlackProbe } from "./probe.js"; 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 { slackSetupAdapter } from "./setup-core.js"; import { parseSlackTarget } from "./targets.js"; import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; const meta = getChatChannelMeta("slack"); const SLACK_CHANNEL_TYPE_CACHE = new Map(); -async function loadSlackChannelRuntime() { - return await import("./channel.runtime.js"); -} - // Select the appropriate Slack token for read/write operations. function getTokenForOperation( account: ResolvedSlackAccount, @@ -328,10 +329,6 @@ async function resolveSlackAllowlistNames(params: { return await resolveSlackUserAllowlist({ token, entries: params.entries }); } -const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ - slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, -})); - export const slackPlugin: ChannelPlugin = { id: "slack", meta: { diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index b0883be083d..486acfd4b2b 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1 +1 @@ -export { handleSlackMessageAction } from "../../../src/plugin-sdk/slack-message-actions.js"; +export { handleSlackMessageAction } from "../../../src/plugin-sdk-internal/slack.js"; diff --git a/extensions/slack/src/plugin-shared.ts b/extensions/slack/src/plugin-shared.ts new file mode 100644 index 00000000000..0c5a6c7957e --- /dev/null +++ b/extensions/slack/src/plugin-shared.ts @@ -0,0 +1,53 @@ +import { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + formatAllowFromLowercase, +} from "../../../src/plugin-sdk-internal/channel-config.js"; +import { type OpenClawConfig } from "../../../src/plugin-sdk-internal/slack.js"; +import { inspectSlackAccount } from "./account-inspect.js"; +import { + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + type ResolvedSlackAccount, +} from "./accounts.js"; +import { createSlackSetupWizardProxy } from "./setup-core.js"; + +async function loadSlackChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export 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()); +} + +export const isSlackPluginAccountConfigured = isSlackAccountConfigured; + +export const slackConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + 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", + listAccountIds: listSlackAccountIds, + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultSlackAccountId, + clearBaseFields: ["botToken", "appToken", "name"], +}); + +export const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ + slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, +})); diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts index 313f472eec4..d7d09dbcb6b 100644 --- a/extensions/slack/src/runtime.ts +++ b/extensions/slack/src/runtime.ts @@ -1,5 +1,7 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + createPluginRuntimeStore, + type PluginRuntime, +} from "../../../src/plugin-sdk-internal/core.js"; const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } = createPluginRuntimeStore("Slack runtime not initialized"); diff --git a/extensions/telegram/src/account-inspect.ts b/extensions/telegram/src/account-inspect.ts index 6aca9122b43..1e428c237fa 100644 --- a/extensions/telegram/src/account-inspect.ts +++ b/extensions/telegram/src/account-inspect.ts @@ -1,4 +1,3 @@ -import type { TelegramAccountConfig } from "openclaw/plugin-sdk/telegram"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { coerceSecretRef, @@ -6,7 +5,8 @@ import { normalizeSecretInputString, } from "../../../src/config/types.secrets.js"; import { tryReadSecretFileSync } from "../../../src/infra/secret-file.js"; -import { resolveAccountWithDefaultFallback } from "../../../src/plugin-sdk/account-resolution.js"; +import { resolveAccountWithDefaultFallback } from "../../../src/plugin-sdk-internal/accounts.js"; +import type { TelegramAccountConfig } from "../../../src/plugin-sdk-internal/telegram.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { resolveDefaultSecretProviderAlias } from "../../../src/secrets/ref-contract.js"; import { diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts index ab94be5845c..6d2255e00a1 100644 --- a/extensions/telegram/src/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -1,5 +1,4 @@ import util from "node:util"; -import type { TelegramAccountConfig, TelegramActionConfig } from "openclaw/plugin-sdk/telegram"; import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { isTruthyEnvValue } from "../../../src/infra/env.js"; @@ -7,7 +6,11 @@ import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; import { listConfiguredAccountIds as listConfiguredAccountIdsFromSection, resolveAccountWithDefaultFallback, -} from "../../../src/plugin-sdk/account-resolution.js"; +} from "../../../src/plugin-sdk-internal/accounts.js"; +import type { + TelegramAccountConfig, + TelegramActionConfig, +} from "../../../src/plugin-sdk-internal/telegram.js"; import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; import { listBoundAccountIds, diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 84548374f05..c9ae46ca823 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -15,8 +15,7 @@ import type { ChannelMessageActionName, } from "../../../src/channels/plugins/types.js"; import type { TelegramActionConfig } from "../../../src/config/types.telegram.js"; -import { readBooleanParam } from "../../../src/plugin-sdk/boolean-param.js"; -import { extractToolSend } from "../../../src/plugin-sdk/tool-send.js"; +import { extractToolSend, readBooleanParam } from "../../../src/plugin-sdk-internal/telegram.js"; import { resolveTelegramPollVisibility } from "../../../src/poll-params.js"; import { createTelegramActionGate, diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index 8cc6b39fc19..c349f5ec053 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -1,78 +1,20 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; -import { - createScopedAccountConfigAccessors, - formatAllowFromLowercase, -} from "openclaw/plugin-sdk/compat"; -import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, getChatChannelMeta, - normalizeAccountId, TelegramConfigSchema, - type OpenClawConfig, -} from "openclaw/plugin-sdk/telegram"; -import { inspectTelegramAccount } from "./account-inspect.js"; + type ChannelPlugin, +} from "../../../src/plugin-sdk-internal/telegram.js"; +import { type ResolvedTelegramAccount } from "./accounts.js"; import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, - type ResolvedTelegramAccount, -} from "./accounts.js"; + findTelegramTokenOwnerAccountId, + formatDuplicateTelegramTokenReason, + telegramConfigAccessors, + telegramConfigBase, +} from "./plugin-shared.js"; import type { TelegramProbe } from "./probe.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; -function findTelegramTokenOwnerAccountId(params: { - cfg: OpenClawConfig; - accountId: string; -}): string | null { - const normalizedAccountId = normalizeAccountId(params.accountId); - const tokenOwners = new Map(); - for (const id of listTelegramAccountIds(params.cfg)) { - const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); - const token = (account.token ?? "").trim(); - if (!token) { - continue; - } - const ownerAccountId = tokenOwners.get(token); - if (!ownerAccountId) { - tokenOwners.set(token, account.accountId); - continue; - } - if (account.accountId === normalizedAccountId) { - return ownerAccountId; - } - } - return null; -} - -function formatDuplicateTelegramTokenReason(params: { - accountId: string; - ownerAccountId: string; -}): string { - return ( - `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + - `account "${params.ownerAccountId}". Keep one owner account per bot token.` - ); -} - -const telegramConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), - resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, -}); - -const telegramConfigBase = createScopedChannelConfigBase({ - sectionKey: "telegram", - listAccountIds: listTelegramAccountIds, - resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultTelegramAccountId, - clearBaseFields: ["botToken", "tokenFile", "name"], -}); - export const telegramSetupPlugin: ChannelPlugin = { id: "telegram", meta: { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 6fcc12552c8..720bc2985b7 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,18 +1,22 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { parseTelegramTopicConversation } from "../../../src/acp/conversation-id.js"; +import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js"; +import { buildExecApprovalPendingReplyPayload } from "../../../src/infra/exec-approval-reply.js"; +import { + type OutboundSendDeps, + resolveOutboundSendDep, +} from "../../../src/infra/outbound/send-deps.js"; import { buildAccountScopedAllowlistConfigEditor, collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, - createScopedAccountConfigAccessors, createScopedDmSecurityResolver, - formatAllowFromLowercase, -} from "openclaw/plugin-sdk/compat"; +} from "../../../src/plugin-sdk-internal/channel-config.js"; import { buildAgentSessionKey, resolveThreadSessionKeys, + type ChannelPlugin, type RoutePeer, -} from "openclaw/plugin-sdk/core"; -import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; +} from "../../../src/plugin-sdk-internal/core.js"; import { buildChannelConfigSchema, buildTokenChannelStatusSummary, @@ -21,7 +25,6 @@ import { getChatChannelMeta, listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, - normalizeAccountId, PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, @@ -30,19 +33,10 @@ import { TelegramConfigSchema, type ChannelMessageActionAdapter, type OpenClawConfig, -} from "openclaw/plugin-sdk/telegram"; -import { parseTelegramTopicConversation } from "../../../src/acp/conversation-id.js"; -import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js"; -import { buildExecApprovalPendingReplyPayload } from "../../../src/infra/exec-approval-reply.js"; -import { - type OutboundSendDeps, - resolveOutboundSendDep, -} from "../../../src/infra/outbound/send-deps.js"; +} from "../../../src/plugin-sdk-internal/telegram.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; -import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, - resolveDefaultTelegramAccountId, resolveTelegramAccount, type ResolvedTelegramAccount, } from "./accounts.js"; @@ -57,6 +51,12 @@ 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 { + findTelegramTokenOwnerAccountId, + formatDuplicateTelegramTokenReason, + telegramConfigAccessors, + telegramConfigBase, +} from "./plugin-shared.js"; import { probeTelegram, type TelegramProbe } from "./probe.js"; import { getTelegramRuntime } from "./runtime.js"; import { sendTypingTelegram } from "./send.js"; @@ -71,40 +71,6 @@ type TelegramSendFn = ReturnType< const meta = getChatChannelMeta("telegram"); -function findTelegramTokenOwnerAccountId(params: { - cfg: OpenClawConfig; - accountId: string; -}): string | null { - const normalizedAccountId = normalizeAccountId(params.accountId); - const tokenOwners = new Map(); - for (const id of listTelegramAccountIds(params.cfg)) { - const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); - const token = (account.token ?? "").trim(); - if (!token) { - continue; - } - const ownerAccountId = tokenOwners.get(token); - if (!ownerAccountId) { - tokenOwners.set(token, account.accountId); - continue; - } - if (account.accountId === normalizedAccountId) { - return ownerAccountId; - } - } - return null; -} - -function formatDuplicateTelegramTokenReason(params: { - accountId: string; - ownerAccountId: string; -}): string { - return ( - `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + - `account "${params.ownerAccountId}". Keep one owner account per bot token.` - ); -} - type TelegramSendOptions = NonNullable[2]>; function buildTelegramSendOptions(params: { @@ -329,23 +295,6 @@ const telegramMessageActions: ChannelMessageActionAdapter = { }, }; -const telegramConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), - resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, -}); - -const telegramConfigBase = createScopedChannelConfigBase({ - sectionKey: "telegram", - listAccountIds: listTelegramAccountIds, - resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultTelegramAccountId, - clearBaseFields: ["botToken", "tokenFile", "name"], -}); - const resolveTelegramDmPolicy = createScopedDmSecurityResolver({ channelKey: "telegram", resolvePolicy: (account) => account.config.dmPolicy, diff --git a/extensions/telegram/src/group-access.ts b/extensions/telegram/src/group-access.ts index b5c30979dbb..e42646a7dcd 100644 --- a/extensions/telegram/src/group-access.ts +++ b/extensions/telegram/src/group-access.ts @@ -7,7 +7,7 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "../../../src/config/types.js"; -import { evaluateMatchedGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js"; +import { evaluateMatchedGroupAccessForPolicy } from "../../../src/plugin-sdk-internal/telegram.js"; import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js"; import { firstDefined } from "./bot-access.js"; diff --git a/extensions/telegram/src/plugin-shared.ts b/extensions/telegram/src/plugin-shared.ts new file mode 100644 index 00000000000..4d33a6ed6f8 --- /dev/null +++ b/extensions/telegram/src/plugin-shared.ts @@ -0,0 +1,68 @@ +import { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + formatAllowFromLowercase, +} from "../../../src/plugin-sdk-internal/channel-config.js"; +import { + normalizeAccountId, + type OpenClawConfig, +} from "../../../src/plugin-sdk-internal/telegram.js"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, + type ResolvedTelegramAccount, +} from "./accounts.js"; + +export function findTelegramTokenOwnerAccountId(params: { + cfg: OpenClawConfig; + accountId: string; +}): string | null { + const normalizedAccountId = normalizeAccountId(params.accountId); + const tokenOwners = new Map(); + for (const id of listTelegramAccountIds(params.cfg)) { + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); + const token = (account.token ?? "").trim(); + if (!token) { + continue; + } + const ownerAccountId = tokenOwners.get(token); + if (!ownerAccountId) { + tokenOwners.set(token, account.accountId); + continue; + } + if (account.accountId === normalizedAccountId) { + return ownerAccountId; + } + } + return null; +} + +export function formatDuplicateTelegramTokenReason(params: { + accountId: string; + ownerAccountId: string; +}): string { + return ( + `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + + `account "${params.ownerAccountId}". Keep one owner account per bot token.` + ); +} + +export const telegramConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveTelegramAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), + resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, +}); + +export const telegramConfigBase = createScopedChannelConfigBase({ + sectionKey: "telegram", + listAccountIds: listTelegramAccountIds, + resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultTelegramAccountId, + clearBaseFields: ["botToken", "tokenFile", "name"], +}); diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index dfa7707f144..cade90c5ad5 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -1,5 +1,5 @@ -import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/telegram"; import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; +import type { TelegramNetworkConfig } from "../../../src/plugin-sdk-internal/telegram.js"; import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js"; import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts index d4e15f463d9..768c15e28f5 100644 --- a/extensions/telegram/src/runtime.ts +++ b/extensions/telegram/src/runtime.ts @@ -1,5 +1,7 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + createPluginRuntimeStore, + type PluginRuntime, +} from "../../../src/plugin-sdk-internal/core.js"; const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } = createPluginRuntimeStore("Telegram runtime not initialized"); diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts index e0009d6b76a..d26d9657ca1 100644 --- a/extensions/telegram/src/token.ts +++ b/extensions/telegram/src/token.ts @@ -1,8 +1,8 @@ -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 { tryReadSecretFileSync } from "../../../src/infra/secret-file.js"; +import type { TelegramAccountConfig } from "../../../src/plugin-sdk-internal/telegram.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none"; diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index c607840dcd3..1d17404a6a2 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -1,6 +1,5 @@ import fs from "node:fs"; import path from "node:path"; -import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "openclaw/plugin-sdk/whatsapp"; import { resolveOAuthDir } from "../../../src/config/paths.js"; import { type OpenClawConfig, @@ -10,6 +9,11 @@ import { resolveAccountEntry, resolveUserPath, } from "../../../src/plugin-sdk-internal/accounts.js"; +import type { + DmPolicy, + GroupPolicy, + WhatsAppAccountConfig, +} from "../../../src/plugin-sdk-internal/whatsapp.js"; import { hasWebCredsSync } from "./auth-store.js"; export type ResolvedWhatsAppAccount = { diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index b352bd2ed73..df13d0b06f5 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -14,7 +14,7 @@ import { resolveWhatsAppGroupToolPolicy, WhatsAppConfigSchema, type ChannelPlugin, -} from "openclaw/plugin-sdk/whatsapp"; +} from "../../../src/plugin-sdk-internal/whatsapp.js"; import { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId, @@ -22,57 +22,9 @@ import { type ResolvedWhatsAppAccount, } from "./accounts.js"; import { webAuthExists } from "./auth-store.js"; +import { whatsappSetupWizardProxy } from "./plugin-shared.js"; import { whatsappSetupAdapter } from "./setup-core.js"; -async function loadWhatsAppChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const whatsappSetupWizardProxy = { - channel: "whatsapp", - status: { - configuredLabel: "linked", - unconfiguredLabel: "not linked", - configuredHint: "linked", - unconfiguredHint: "not linked", - configuredScore: 5, - unconfiguredScore: 4, - resolveConfigured: async ({ cfg }) => - await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.status.resolveConfigured({ - cfg, - }), - resolveStatusLines: async ({ cfg, configured }) => - (await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.status.resolveStatusLines?.({ - cfg, - configured, - })) ?? [], - }, - resolveShouldPromptAccountIds: (params) => - (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, - credentials: [], - finalize: async (params) => - await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.finalize!(params), - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - enabled: false, - }, - }, - }), - onAccountRecorded: (accountId, options) => { - options?.onWhatsAppAccountId?.(accountId); - }, -} satisfies NonNullable["setupWizard"]>; - export const whatsappSetupPlugin: ChannelPlugin = { id: "whatsapp", meta: { diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index d7f437d3204..3f2c2e449dc 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,4 +1,4 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat"; +import { buildAccountScopedAllowlistConfigEditor } from "../../../src/plugin-sdk-internal/channel-config.js"; import { buildChannelConfigSchema, buildAccountScopedDmSecurityPolicy, @@ -24,7 +24,7 @@ import { WhatsAppConfigSchema, type ChannelMessageActionName, type ChannelPlugin, -} from "openclaw/plugin-sdk/whatsapp"; +} from "../../../src/plugin-sdk-internal/whatsapp.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../../src/whatsapp/normalize.js"; // WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) import { @@ -34,16 +34,13 @@ import { type ResolvedWhatsAppAccount, } from "./accounts.js"; import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; +import { whatsappSetupWizardProxy } from "./plugin-shared.js"; import { getWhatsAppRuntime } from "./runtime.js"; import { whatsappSetupAdapter } from "./setup-core.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; const meta = getChatChannelMeta("whatsapp"); -async function loadWhatsAppChannelRuntime() { - return await import("./channel.runtime.js"); -} - function normalizeWhatsAppPayloadText(text: string | undefined): string { return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); } @@ -59,51 +56,6 @@ function parseWhatsAppExplicitTarget(raw: string) { }; } -const whatsappSetupWizardProxy = { - channel: "whatsapp", - status: { - configuredLabel: "linked", - unconfiguredLabel: "not linked", - configuredHint: "linked", - unconfiguredHint: "not linked", - configuredScore: 5, - unconfiguredScore: 4, - resolveConfigured: async ({ cfg }) => - await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.status.resolveConfigured({ - cfg, - }), - resolveStatusLines: async ({ cfg, configured }) => - (await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.status.resolveStatusLines?.({ - cfg, - configured, - })) ?? [], - }, - resolveShouldPromptAccountIds: (params) => - (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, - credentials: [], - finalize: async (params) => - await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.finalize!(params), - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - enabled: false, - }, - }, - }), - onAccountRecorded: (accountId, options) => { - options?.onWhatsAppAccountId?.(accountId); - }, -} satisfies NonNullable["setupWizard"]>; - export const whatsappPlugin: ChannelPlugin = { id: "whatsapp", meta: { diff --git a/extensions/whatsapp/src/plugin-shared.ts b/extensions/whatsapp/src/plugin-shared.ts new file mode 100644 index 00000000000..1ab5d80220c --- /dev/null +++ b/extensions/whatsapp/src/plugin-shared.ts @@ -0,0 +1,51 @@ +import { type ChannelPlugin } from "../../../src/plugin-sdk-internal/whatsapp.js"; +import { type ResolvedWhatsAppAccount } from "./accounts.js"; + +async function loadWhatsAppChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const whatsappSetupWizardProxy = { + channel: "whatsapp", + status: { + configuredLabel: "linked", + unconfiguredLabel: "not linked", + configuredHint: "linked", + unconfiguredHint: "not linked", + configuredScore: 5, + unconfiguredScore: 4, + resolveConfigured: async ({ cfg }) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveConfigured({ + cfg, + }), + resolveStatusLines: async ({ cfg, configured }) => + (await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveStatusLines?.({ + cfg, + configured, + })) ?? [], + }, + resolveShouldPromptAccountIds: (params) => + (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, + credentials: [], + finalize: async (params) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.finalize!(params), + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + enabled: false, + }, + }, + }), + onAccountRecorded: (accountId, options) => { + options?.onWhatsAppAccountId?.(accountId); + }, +} satisfies NonNullable["setupWizard"]>; diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index 07dd4e3d688..e103cc878f0 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -1,5 +1,7 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + createPluginRuntimeStore, + type PluginRuntime, +} from "../../../src/plugin-sdk-internal/core.js"; const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = createPluginRuntimeStore("WhatsApp runtime not initialized"); diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index e2ec4149631..41204ecfcb9 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -13,6 +13,7 @@ import { type OpenClawConfig, } from "../../../src/plugin-sdk-internal/setup.js"; import type { ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; +import { type DmPolicy } from "../../../src/plugin-sdk-internal/whatsapp.js"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; import { loginWeb } from "./login.js"; import { whatsappSetupAdapter } from "./setup-core.js"; diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index a45abc3ff0b..461be379261 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -402,7 +402,6 @@ export function installChannelDirectoryContractSuite(params: { if (params.invokeLookups === false) { return; } - const self = await directory?.self?.({ cfg: {} as OpenClawConfig, accountId: "default", diff --git a/src/plugin-sdk-internal/accounts.ts b/src/plugin-sdk-internal/accounts.ts index 853d41c5f42..71807c97c6e 100644 --- a/src/plugin-sdk-internal/accounts.ts +++ b/src/plugin-sdk-internal/accounts.ts @@ -3,6 +3,10 @@ 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 { + listConfiguredAccountIds, + resolveAccountWithDefaultFallback, +} from "../plugin-sdk/account-resolution.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/channel-config.ts b/src/plugin-sdk-internal/channel-config.ts new file mode 100644 index 00000000000..64b62fb77b0 --- /dev/null +++ b/src/plugin-sdk-internal/channel-config.ts @@ -0,0 +1,17 @@ +// Private bridge for bundled channel plugins. These config helpers are shared +// internally, but do not belong on the public compat surface. +export { buildAccountScopedAllowlistConfigEditor } from "../plugin-sdk/allowlist-config-edit.js"; +export { formatAllowFromLowercase } from "../plugin-sdk/allow-from.js"; +export { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, +} from "../plugin-sdk/channel-config-helpers.js"; +export { + collectAllowlistProviderGroupPolicyWarnings, + collectAllowlistProviderRestrictSendersWarnings, + collectOpenGroupPolicyConfiguredRouteWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, + collectOpenProviderGroupPolicyWarnings, +} from "../channels/plugins/group-policy-warnings.js"; +export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; diff --git a/src/plugin-sdk-internal/core.ts b/src/plugin-sdk-internal/core.ts new file mode 100644 index 00000000000..aa5ef23268d --- /dev/null +++ b/src/plugin-sdk-internal/core.ts @@ -0,0 +1,14 @@ +// Private bridge for bundled channel plugins. Keep public sdk/core slim for +// third-party plugins; bundled channels can reach shared runtime helpers here. +export type { + ChannelMessageActionContext, + OpenClawPluginApi, + PluginRuntime, +} from "../plugin-sdk/channel-plugin-common.js"; +export { createPluginRuntimeStore } from "../plugin-sdk/runtime-store.js"; +export { + buildAgentSessionKey, + type RoutePeer, + type RoutePeerKind, +} from "../routing/resolve-route.js"; +export { resolveThreadSessionKeys } from "../routing/session-key.js"; diff --git a/src/plugin-sdk-internal/imessage.ts b/src/plugin-sdk-internal/imessage.ts index 170dd7ff188..757885fc616 100644 --- a/src/plugin-sdk-internal/imessage.ts +++ b/src/plugin-sdk-internal/imessage.ts @@ -11,6 +11,7 @@ export { resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, } from "../plugin-sdk/channel-config-helpers.js"; +export { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js"; export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, diff --git a/src/plugin-sdk-internal/signal.ts b/src/plugin-sdk-internal/signal.ts index 4594420af8d..6b938e66518 100644 --- a/src/plugin-sdk-internal/signal.ts +++ b/src/plugin-sdk-internal/signal.ts @@ -1,4 +1,5 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; +export type { OpenClawConfig } from "../config/config.js"; export type { ResolvedSignalAccount } from "../../extensions/signal/src/accounts.js"; export type { SignalAccountConfig } from "../config/types.js"; export * from "../plugin-sdk/channel-plugin-common.js"; @@ -23,6 +24,7 @@ export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; +export { evaluateSenderGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; export { signalSetupWizard } from "../../extensions/signal/src/setup-surface.js"; export { signalSetupAdapter } from "../../extensions/signal/src/setup-core.js"; export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; diff --git a/src/plugin-sdk-internal/telegram.ts b/src/plugin-sdk-internal/telegram.ts index bb983d690d1..d5dd45a96d6 100644 --- a/src/plugin-sdk-internal/telegram.ts +++ b/src/plugin-sdk-internal/telegram.ts @@ -7,7 +7,11 @@ 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 type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js"; export type { TelegramProbe } from "../../extensions/telegram/src/probe.js"; @@ -102,6 +106,9 @@ export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; +export { readBooleanParam } from "../plugin-sdk/boolean-param.js"; +export { evaluateMatchedGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; +export { extractToolSend } from "../plugin-sdk/tool-send.js"; export { resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, From 85781353ec8c2372ea9097b5999e3af31db64846 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:01:08 -0700 Subject: [PATCH 026/128] feat(plugins): expand speech runtime ownership --- extensions/talk-voice/index.ts | 103 ++++++++++++------ extensions/test-utils/plugin-runtime-mock.ts | 2 + .../contracts/registry.contract.test.ts | 35 ++++++ src/plugins/contracts/registry.ts | 26 ++++- src/plugins/runtime/index.ts | 4 +- src/plugins/runtime/types-core.ts | 2 + src/plugins/types.ts | 3 + src/tts/provider-types.ts | 14 +++ src/tts/providers/elevenlabs.ts | 52 +++++++++ src/tts/providers/openai.ts | 1 + src/tts/tts.ts | 31 ++++++ 11 files changed, 236 insertions(+), 37 deletions(-) diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index 3445e91e81f..3c8ee3ba09e 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -1,11 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/talk-voice"; - -type ElevenLabsVoice = { - voice_id: string; - name?: string; - category?: string; - description?: string; -}; +import { resolveActiveTalkProviderConfig } from "../../src/config/talk.js"; +import type { SpeechVoiceOption } from "../../src/tts/provider-types.js"; function mask(s: string, keep: number = 6): string { const trimmed = s.trim(); @@ -23,30 +18,30 @@ function isLikelyVoiceId(value: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(v); } -async function listVoices(apiKey: string): Promise { - const res = await fetch("https://api.elevenlabs.io/v1/voices", { - headers: { - "xi-api-key": apiKey, - }, - }); - if (!res.ok) { - throw new Error(`ElevenLabs voices API error (${res.status})`); +function resolveProviderLabel(providerId: string): string { + switch (providerId) { + case "openai": + return "OpenAI"; + case "microsoft": + return "Microsoft"; + case "elevenlabs": + return "ElevenLabs"; + default: + return providerId; } - const json = (await res.json()) as { voices?: ElevenLabsVoice[] }; - return Array.isArray(json.voices) ? json.voices : []; } -function formatVoiceList(voices: ElevenLabsVoice[], limit: number): string { +function formatVoiceList(voices: SpeechVoiceOption[], limit: number, providerId: string): string { const sliced = voices.slice(0, Math.max(1, Math.min(limit, 50))); const lines: string[] = []; - lines.push(`Voices: ${voices.length}`); + lines.push(`${resolveProviderLabel(providerId)} voices: ${voices.length}`); lines.push(""); for (const v of sliced) { const name = (v.name ?? "").trim() || "(unnamed)"; const category = (v.category ?? "").trim(); const meta = category ? ` · ${category}` : ""; lines.push(`- ${name}${meta}`); - lines.push(` id: ${v.voice_id}`); + lines.push(` id: ${v.id}`); } if (voices.length > sliced.length) { lines.push(""); @@ -55,13 +50,13 @@ function formatVoiceList(voices: ElevenLabsVoice[], limit: number): string { return lines.join("\n"); } -function findVoice(voices: ElevenLabsVoice[], query: string): ElevenLabsVoice | null { +function findVoice(voices: SpeechVoiceOption[], query: string): SpeechVoiceOption | null { const q = query.trim(); if (!q) { return null; } const lower = q.toLowerCase(); - const byId = voices.find((v) => v.voice_id === q); + const byId = voices.find((v) => v.id === q); if (byId) { return byId; } @@ -81,13 +76,18 @@ function resolveCommandLabel(channel: string): string { return channel === "discord" ? "/talkvoice" : "/voice"; } +function asProviderBaseUrl(value: unknown): string | undefined { + const trimmed = asTrimmedString(value); + return trimmed || undefined; +} + export default function register(api: OpenClawPluginApi) { api.registerCommand({ name: "voice", nativeNames: { discord: "talkvoice", }, - description: "List/set ElevenLabs Talk voice (affects iOS Talk playback).", + description: "List/set Talk provider voices (affects iOS Talk playback).", acceptsArgs: true, handler: async (ctx) => { const commandLabel = resolveCommandLabel(ctx.channel); @@ -96,31 +96,49 @@ export default function register(api: OpenClawPluginApi) { const action = (tokens[0] ?? "status").toLowerCase(); const cfg = api.runtime.config.loadConfig(); - const apiKey = asTrimmedString(cfg.talk?.apiKey); - if (!apiKey) { + const active = resolveActiveTalkProviderConfig(cfg.talk); + if (!active) { return { text: "Talk voice is not configured.\n\n" + - "Missing: talk.apiKey (ElevenLabs API key).\n" + + "Missing: talk.provider and talk.providers..\n" + "Set it on the gateway, then retry.", }; } + const providerId = active.provider; + const providerLabel = resolveProviderLabel(providerId); + const apiKey = asTrimmedString(active.config.apiKey); + const baseUrl = asProviderBaseUrl(active.config.baseUrl); - const currentVoiceId = (cfg.talk?.voiceId ?? "").trim(); + const currentVoiceId = + asTrimmedString(active.config.voiceId) || asTrimmedString(cfg.talk?.voiceId); if (action === "status") { return { text: "Talk voice status:\n" + + `- provider: ${providerId}\n` + `- talk.voiceId: ${currentVoiceId ? currentVoiceId : "(unset)"}\n` + - `- talk.apiKey: ${mask(apiKey)}`, + `- ${providerId}.apiKey: ${apiKey ? mask(apiKey) : "(unset)"}`, }; } if (action === "list") { const limit = Number.parseInt(tokens[1] ?? "12", 10); - const voices = await listVoices(apiKey); - return { text: formatVoiceList(voices, Number.isFinite(limit) ? limit : 12) }; + try { + const voices = await api.runtime.tts.listVoices({ + provider: providerId, + cfg, + apiKey: apiKey || undefined, + baseUrl, + }); + return { + text: formatVoiceList(voices, Number.isFinite(limit) ? limit : 12, providerId), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { text: `${providerLabel} voice list failed: ${message}` }; + } } if (action === "set") { @@ -128,7 +146,18 @@ export default function register(api: OpenClawPluginApi) { if (!query) { return { text: `Usage: ${commandLabel} set ` }; } - const voices = await listVoices(apiKey); + let voices: SpeechVoiceOption[]; + try { + voices = await api.runtime.tts.listVoices({ + provider: providerId, + cfg, + apiKey: apiKey || undefined, + baseUrl, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { text: `${providerLabel} voice lookup failed: ${message}` }; + } const chosen = findVoice(voices, query); if (!chosen) { const hint = isLikelyVoiceId(query) ? query : `"${query}"`; @@ -139,13 +168,21 @@ export default function register(api: OpenClawPluginApi) { ...cfg, talk: { ...cfg.talk, - voiceId: chosen.voice_id, + provider: providerId, + providers: { + ...(cfg.talk?.providers ?? {}), + [providerId]: { + ...(cfg.talk?.providers?.[providerId] ?? {}), + voiceId: chosen.id, + }, + }, + ...(providerId === "elevenlabs" ? { voiceId: chosen.id } : {}), }, }; await api.runtime.config.writeConfigFile(nextConfig); const name = (chosen.name ?? "").trim() || "(unnamed)"; - return { text: `✅ Talk voice set to ${name}\n${chosen.voice_id}` }; + return { text: `✅ ${providerLabel} Talk voice set to ${name}\n${chosen.id}` }; } return { diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/extensions/test-utils/plugin-runtime-mock.ts index 19a17e0811a..22521ee833d 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/extensions/test-utils/plugin-runtime-mock.ts @@ -102,7 +102,9 @@ export function createPluginRuntimeMock(overrides: DeepPartial = resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"], }, tts: { + textToSpeech: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeech"], textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"], + listVoices: vi.fn() as unknown as PluginRuntime["tts"]["listVoices"], }, stt: { transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"], diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 2bf113fe76d..cf728b9a91b 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { pluginRegistrationContractRegistry, providerContractRegistry, + speechProviderContractRegistry, webSearchProviderContractRegistry, } from "./registry.js"; @@ -19,6 +20,13 @@ function findWebSearchIdsForPlugin(pluginId: string) { .toSorted((left, right) => left.localeCompare(right)); } +function findSpeechProviderIdsForPlugin(pluginId: string) { + return speechProviderContractRegistry + .filter((entry) => entry.pluginId === pluginId) + .map((entry) => entry.provider.id) + .toSorted((left, right) => left.localeCompare(right)); +} + function findRegistrationForPlugin(pluginId: string) { const entry = pluginRegistrationContractRegistry.find( (candidate) => candidate.pluginId === pluginId, @@ -40,6 +48,11 @@ describe("plugin contract registry", () => { expect(ids).toEqual([...new Set(ids)]); }); + it("does not duplicate bundled speech provider ids", () => { + const ids = speechProviderContractRegistry.map((entry) => entry.provider.id); + expect(ids).toEqual([...new Set(ids)]); + }); + it("keeps multi-provider plugin ownership explicit", () => { expect(findProviderIdsForPlugin("google")).toEqual(["google", "google-gemini-cli"]); expect(findProviderIdsForPlugin("minimax")).toEqual(["minimax", "minimax-portal"]); @@ -55,11 +68,33 @@ describe("plugin contract registry", () => { expect(findWebSearchIdsForPlugin("xai")).toEqual(["grok"]); }); + it("keeps bundled speech ownership explicit", () => { + expect(findSpeechProviderIdsForPlugin("elevenlabs")).toEqual(["elevenlabs"]); + expect(findSpeechProviderIdsForPlugin("microsoft")).toEqual(["microsoft"]); + expect(findSpeechProviderIdsForPlugin("openai")).toEqual(["openai"]); + }); + it("keeps bundled provider and web search tool ownership explicit", () => { expect(findRegistrationForPlugin("firecrawl")).toMatchObject({ providerIds: [], + speechProviderIds: [], webSearchProviderIds: ["firecrawl"], toolNames: ["firecrawl_search", "firecrawl_scrape"], }); }); + + it("tracks speech registrations on bundled provider plugins", () => { + expect(findRegistrationForPlugin("openai")).toMatchObject({ + providerIds: ["openai", "openai-codex"], + speechProviderIds: ["openai"], + }); + expect(findRegistrationForPlugin("elevenlabs")).toMatchObject({ + providerIds: [], + speechProviderIds: ["elevenlabs"], + }); + expect(findRegistrationForPlugin("microsoft")).toMatchObject({ + providerIds: [], + speechProviderIds: ["microsoft"], + }); + }); }); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 8099ce4ca44..1dc997d7b2e 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -3,12 +3,14 @@ import bravePlugin from "../../../extensions/brave/index.js"; import byteplusPlugin from "../../../extensions/byteplus/index.js"; import cloudflareAiGatewayPlugin from "../../../extensions/cloudflare-ai-gateway/index.js"; import copilotProxyPlugin from "../../../extensions/copilot-proxy/index.js"; +import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js"; import firecrawlPlugin from "../../../extensions/firecrawl/index.js"; import githubCopilotPlugin from "../../../extensions/github-copilot/index.js"; import googlePlugin from "../../../extensions/google/index.js"; import huggingFacePlugin from "../../../extensions/huggingface/index.js"; import kilocodePlugin from "../../../extensions/kilocode/index.js"; import kimiCodingPlugin from "../../../extensions/kimi-coding/index.js"; +import microsoftPlugin from "../../../extensions/microsoft/index.js"; import minimaxPlugin from "../../../extensions/minimax/index.js"; import mistralPlugin from "../../../extensions/mistral/index.js"; import modelStudioPlugin from "../../../extensions/modelstudio/index.js"; @@ -33,7 +35,7 @@ import xaiPlugin from "../../../extensions/xai/index.js"; import xiaomiPlugin from "../../../extensions/xiaomi/index.js"; import zaiPlugin from "../../../extensions/zai/index.js"; import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; -import type { ProviderPlugin, WebSearchProviderPlugin } from "../types.js"; +import type { ProviderPlugin, SpeechProviderPlugin, WebSearchProviderPlugin } from "../types.js"; type RegistrablePlugin = { id: string; @@ -51,9 +53,15 @@ type WebSearchProviderContractEntry = { credentialValue: unknown; }; +type SpeechProviderContractEntry = { + pluginId: string; + provider: SpeechProviderPlugin; +}; + type PluginRegistrationContractEntry = { pluginId: string; providerIds: string[]; + speechProviderIds: string[]; webSearchProviderIds: string[]; toolNames: string[]; }; @@ -101,6 +109,8 @@ const bundledWebSearchPlugins: Array { + const captured = captureRegistrations(plugin); + return captured.speechProviders.map((provider) => ({ + pluginId: plugin.id, + provider, + })); + }); + const bundledPluginRegistrationList = [ ...new Map( - [...bundledProviderPlugins, ...bundledWebSearchPlugins].map((plugin) => [plugin.id, plugin]), + [...bundledProviderPlugins, ...bundledSpeechPlugins, ...bundledWebSearchPlugins].map( + (plugin) => [plugin.id, plugin], + ), ).values(), ]; @@ -139,6 +160,7 @@ export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry return { pluginId: plugin.id, providerIds: captured.providers.map((provider) => provider.id), + speechProviderIds: captured.speechProviders.map((provider) => provider.id), webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id), toolNames: captured.tools.map((tool) => tool.name), }; diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index d94825062cd..3ae024aad2b 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -5,7 +5,7 @@ import { } from "../../agents/model-auth.js"; import { resolveStateDir } from "../../config/paths.js"; import { transcribeAudioFile } from "../../media-understanding/transcribe-audio.js"; -import { textToSpeechTelephony } from "../../tts/tts.js"; +import { listSpeechVoices, textToSpeech, textToSpeechTelephony } from "../../tts/tts.js"; import { createRuntimeAgent } from "./runtime-agent.js"; import { createRuntimeChannel } from "./runtime-channel.js"; import { createRuntimeConfig } from "./runtime-config.js"; @@ -135,7 +135,7 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): ), system: createRuntimeSystem(), media: createRuntimeMedia(), - tts: { textToSpeechTelephony }, + tts: { textToSpeech, textToSpeechTelephony, listVoices: listSpeechVoices }, stt: { transcribeAudioFile }, tools: createRuntimeTools(), channel: createRuntimeChannel(), diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index c1bb753fb11..a81a6ad6545 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -47,7 +47,9 @@ export type PluginRuntimeCore = { resizeToJpeg: typeof import("../../media/image-ops.js").resizeToJpeg; }; tts: { + textToSpeech: typeof import("../../tts/tts.js").textToSpeech; textToSpeechTelephony: typeof import("../../tts/tts.js").textToSpeechTelephony; + listVoices: typeof import("../../tts/tts.js").listSpeechVoices; }; stt: { transcribeAudioFile: typeof import("../../media-understanding/transcribe-audio.js").transcribeAudioFile; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 2a2e2b9fd5f..0add5cdcf42 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -29,11 +29,13 @@ import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; import type { SpeechProviderConfiguredContext, + SpeechListVoicesRequest, SpeechProviderId, SpeechSynthesisRequest, SpeechSynthesisResult, SpeechTelephonySynthesisRequest, SpeechTelephonySynthesisResult, + SpeechVoiceOption, } from "../tts/provider-types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -872,6 +874,7 @@ export type SpeechProviderPlugin = { synthesizeTelephony?: ( req: SpeechTelephonySynthesisRequest, ) => Promise; + listVoices?: (req: SpeechListVoicesRequest) => Promise; }; export type PluginSpeechProviderEntry = SpeechProviderPlugin & { diff --git a/src/tts/provider-types.ts b/src/tts/provider-types.ts index bfbeb38f02a..be0a083127d 100644 --- a/src/tts/provider-types.ts +++ b/src/tts/provider-types.ts @@ -36,3 +36,17 @@ export type SpeechTelephonySynthesisResult = { outputFormat: string; sampleRate: number; }; + +export type SpeechVoiceOption = { + id: string; + name?: string; + category?: string; + description?: string; +}; + +export type SpeechListVoicesRequest = { + cfg?: OpenClawConfig; + config?: ResolvedTtsConfig; + apiKey?: string; + baseUrl?: string; +}; diff --git a/src/tts/providers/elevenlabs.ts b/src/tts/providers/elevenlabs.ts index 2b6df133edc..c22425926bf 100644 --- a/src/tts/providers/elevenlabs.ts +++ b/src/tts/providers/elevenlabs.ts @@ -1,4 +1,5 @@ import type { SpeechProviderPlugin } from "../../plugins/types.js"; +import type { SpeechVoiceOption } from "../provider-types.js"; import { elevenLabsTTS } from "../tts-core.js"; const ELEVENLABS_TTS_MODELS = [ @@ -7,11 +8,62 @@ const ELEVENLABS_TTS_MODELS = [ "eleven_monolingual_v1", ] as const; +function normalizeElevenLabsBaseUrl(baseUrl: string | undefined): string { + const trimmed = baseUrl?.trim(); + return trimmed?.replace(/\/+$/, "") || "https://api.elevenlabs.io"; +} + +export async function listElevenLabsVoices(params: { + apiKey: string; + baseUrl?: string; +}): Promise { + const res = await fetch(`${normalizeElevenLabsBaseUrl(params.baseUrl)}/v1/voices`, { + headers: { + "xi-api-key": params.apiKey, + }, + }); + if (!res.ok) { + throw new Error(`ElevenLabs voices API error (${res.status})`); + } + const json = (await res.json()) as { + voices?: Array<{ + voice_id?: string; + name?: string; + category?: string; + description?: string; + }>; + }; + return Array.isArray(json.voices) + ? json.voices + .map((voice) => ({ + id: voice.voice_id?.trim() ?? "", + name: voice.name?.trim() || undefined, + category: voice.category?.trim() || undefined, + description: voice.description?.trim() || undefined, + })) + .filter((voice) => voice.id.length > 0) + : []; +} + export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin { return { id: "elevenlabs", label: "ElevenLabs", models: ELEVENLABS_TTS_MODELS, + listVoices: async (req) => { + const apiKey = + req.apiKey || + req.config?.elevenlabs.apiKey || + process.env.ELEVENLABS_API_KEY || + process.env.XI_API_KEY; + if (!apiKey) { + throw new Error("ElevenLabs API key missing"); + } + return listElevenLabsVoices({ + apiKey, + baseUrl: req.baseUrl ?? req.config?.elevenlabs.baseUrl, + }); + }, isConfigured: ({ config }) => Boolean(config.elevenlabs.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY), synthesize: async (req) => { diff --git a/src/tts/providers/openai.ts b/src/tts/providers/openai.ts index bf52c1644a9..9f96e9ea6e9 100644 --- a/src/tts/providers/openai.ts +++ b/src/tts/providers/openai.ts @@ -7,6 +7,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin { label: "OpenAI", models: OPENAI_TTS_MODELS, voices: OPENAI_TTS_VOICES, + listVoices: async () => OPENAI_TTS_VOICES.map((voice) => ({ id: voice, name: voice })), 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; diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 44cb57fd6e8..39793fd2ba4 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -30,6 +30,7 @@ import { listSpeechProviders, normalizeSpeechProviderId, } from "./provider-registry.js"; +import type { SpeechVoiceOption } from "./provider-types.js"; import { DEFAULT_OPENAI_BASE_URL, isValidOpenAIModel, @@ -723,6 +724,36 @@ export async function textToSpeechTelephony(params: { return buildTtsFailureResult(errors); } +export async function listSpeechVoices(params: { + provider: string; + cfg?: OpenClawConfig; + config?: ResolvedTtsConfig; + apiKey?: string; + baseUrl?: string; +}): Promise { + const provider = normalizeSpeechProviderId(params.provider); + if (!provider) { + throw new Error("speech provider id is required"); + } + const config = params.config ?? (params.cfg ? resolveTtsConfig(params.cfg) : undefined); + if (!config) { + throw new Error(`speech provider ${provider} requires cfg or resolved config`); + } + const resolvedProvider = getSpeechProvider(provider, params.cfg); + if (!resolvedProvider) { + throw new Error(`speech provider ${provider} is not registered`); + } + if (!resolvedProvider.listVoices) { + throw new Error(`speech provider ${provider} does not support voice listing`); + } + return await resolvedProvider.listVoices({ + cfg: params.cfg, + config, + apiKey: params.apiKey, + baseUrl: params.baseUrl, + }); +} + export async function maybeApplyTtsToPayload(params: { payload: ReplyPayload; cfg: OpenClawConfig; From ed248c76c742bead58c68016a9cef969177682a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:01:17 -0700 Subject: [PATCH 027/128] docs(plugins): document speech runtime ownership --- docs/tools/plugin.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 3e53c5e205e..8b8de658785 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -331,7 +331,8 @@ There are two layers of enforcement: 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. + providers, speech 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 @@ -649,19 +650,31 @@ to think of as short-lived performance caches, not persistence. ## Runtime helpers -Plugins can access selected core helpers via `api.runtime`. For telephony TTS: +Plugins can access selected core helpers via `api.runtime`. For TTS: ```ts +const clip = await api.runtime.tts.textToSpeech({ + text: "Hello from OpenClaw", + cfg: api.config, +}); + const result = await api.runtime.tts.textToSpeechTelephony({ text: "Hello from OpenClaw", cfg: api.config, }); + +const voices = await api.runtime.tts.listVoices({ + provider: "elevenlabs", + cfg: api.config, +}); ``` Notes: +- `textToSpeech` returns the normal core TTS output payload for file/voice-note surfaces. - Uses core `messages.tts` configuration and provider selection. - Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers. +- `listVoices` is optional per provider. Use it for vendor-owned voice pickers or setup flows. - OpenAI and ElevenLabs support telephony today. Microsoft does not. Plugins can also register speech providers via `api.registerSpeechProvider(...)`. From 1ffe8fde84d1c558a23d3ae985800c7bcfaf06a6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:01:11 +0000 Subject: [PATCH 028/128] fix: stabilize docker test suite --- CHANGELOG.md | 1 + docs/help/testing.md | 8 +- package.json | 2 +- pnpm-lock.yaml | 2 +- scripts/docker/cleanup-smoke/Dockerfile | 2 + scripts/e2e/Dockerfile | 7 +- scripts/e2e/doctor-install-switch-docker.sh | 2 +- scripts/e2e/onboard-docker.sh | 33 +- scripts/e2e/plugins-docker.sh | 2 +- scripts/test-live-gateway-models-docker.sh | 9 +- scripts/test-live-models-docker.sh | 12 +- .../auth-profiles.external-cli-sync.test.ts | 36 ++ src/agents/auth-profiles/external-cli-sync.ts | 97 +-- src/agents/cli-credentials.test.ts | 18 +- src/agents/cli-credentials.ts | 26 +- src/agents/models.profiles.live.test.ts | 13 + .../gateway-models.profiles.live.test.ts | 562 +++++++++--------- 17 files changed, 450 insertions(+), 382 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d948e2b59ee..24335d41a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai - 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. (#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. +- Docker/live tests: mount external CLI auth homes into writable container copies, derive Codex OAuth expiry from JWT `exp`, refresh synced CLI creds instead of trusting stale cached expiry, and make gateway live probes wait on transcript output so `pnpm test:docker:all` stays green in Linux. - 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. diff --git a/docs/help/testing.md b/docs/help/testing.md index 09388dd769e..ab63db23670 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -362,7 +362,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local ## Docker runners (optional “works in Linux” checks) -These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). They also bind-mount CLI auth homes like `~/.codex`, `~/.claude`, `~/.qwen`, and `~/.minimax` when present so external-CLI OAuth stays available in-container: +These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). They also bind-mount CLI auth homes like `~/.codex`, `~/.claude`, `~/.qwen`, and `~/.minimax` when present, then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: - Direct models: `pnpm test:docker:live-models` (script: `scripts/test-live-models-docker.sh`) - Gateway + dev agent: `pnpm test:docker:live-gateway` (script: `scripts/test-live-gateway-models-docker.sh`) @@ -373,6 +373,9 @@ These run `pnpm test:live` inside the repo Docker image, mounting your local con The live-model Docker runners also bind-mount the current checkout read-only and stage it into a temporary workdir inside the container. This keeps the runtime image slim while still running Vitest against your exact local source/config. +`test:docker:live-models` still runs `pnpm test:live`, so pass through +`OPENCLAW_LIVE_GATEWAY_*` as well when you need to narrow or exclude gateway +live coverage from that Docker lane. Manual ACP plain-language thread smoke (not CI): @@ -384,8 +387,9 @@ Useful env vars: - `OPENCLAW_CONFIG_DIR=...` (default: `~/.openclaw`) mounted to `/home/node/.openclaw` - `OPENCLAW_WORKSPACE_DIR=...` (default: `~/.openclaw/workspace`) mounted to `/home/node/.openclaw/workspace` - `OPENCLAW_PROFILE_FILE=...` (default: `~/.profile`) mounted to `/home/node/.profile` and sourced before running tests -- External CLI auth dirs under `$HOME` (`.codex`, `.claude`, `.qwen`, `.minimax`) are mounted read-only to the matching `/home/node/...` paths when present +- External CLI auth dirs under `$HOME` (`.codex`, `.claude`, `.qwen`, `.minimax`) are mounted read-only under `/host-auth/...`, then copied into `/home/node/...` before tests start - `OPENCLAW_LIVE_GATEWAY_MODELS=...` / `OPENCLAW_LIVE_MODELS=...` to narrow the run +- `OPENCLAW_LIVE_GATEWAY_PROVIDERS=...` / `OPENCLAW_LIVE_PROVIDERS=...` to filter providers in-container - `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to ensure creds come from the profile store (not env) ## Docs sanity diff --git a/package.json b/package.json index f0904418919..eaae91d6a40 100644 --- a/package.json +++ b/package.json @@ -401,7 +401,7 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "file-type": "^21.3.2", - "gaxios": "^7.1.3", + "gaxios": "7.1.3", "grammy": "^1.41.1", "hono": "4.12.7", "https-proxy-agent": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90ebda912b0..e05340832b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,7 +126,7 @@ importers: specifier: 21.3.2 version: 21.3.2 gaxios: - specifier: ^7.1.3 + specifier: 7.1.3 version: 7.1.3 grammy: specifier: ^1.41.1 diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile index 07a2334aa41..f214ffbabf4 100644 --- a/scripts/docker/cleanup-smoke/Dockerfile +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -2,6 +2,8 @@ FROM node:24-bookworm-slim@sha256:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6e272eebf0418f276ba +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-cleanup-smoke-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index 4669e762c4a..2c23c9ef1b8 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -20,7 +20,7 @@ WORKDIR /app COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY --chown=appuser:appuser ui/package.json ./ui/package.json -COPY --chown=appuser:appuser extensions/memory-core/package.json ./extensions/memory-core/package.json +COPY --chown=appuser:appuser extensions ./extensions COPY --chown=appuser:appuser patches ./patches RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \ @@ -39,6 +39,9 @@ COPY --chown=appuser:appuser apps/shared/OpenClawKit/Sources/OpenClawKit/Resourc COPY --chown=appuser:appuser apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI RUN pnpm build -RUN pnpm ui:build +# Onboard Docker E2E does not exercise the Control UI itself; it only needs the +# asset-existence check to pass so configure/onboard can continue. +RUN mkdir -p dist/control-ui \ + && printf '%s\n' 'OpenClaw Control UI' > dist/control-ui/index.html CMD ["bash"] diff --git a/scripts/e2e/doctor-install-switch-docker.sh b/scripts/e2e/doctor-install-switch-docker.sh index ca91619ef5a..4ca742a362b 100755 --- a/scripts/e2e/doctor-install-switch-docker.sh +++ b/scripts/e2e/doctor-install-switch-docker.sh @@ -75,7 +75,7 @@ LOGINCTL # Install the npm-global variant from the local /app source. # `npm pack` can emit script output; keep only the tarball name. - pkg_tgz="$(npm pack --silent /app | tail -n 1 | tr -d '\r')" + pkg_tgz="$(npm pack --ignore-scripts --silent /app | tail -n 1 | tr -d '\r')" if [ ! -f "/app/$pkg_tgz" ]; then echo "npm pack failed (expected /app/$pkg_tgz)" exit 1 diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh index 49b08dcc2ca..70cbd6f0c51 100755 --- a/scripts/e2e/onboard-docker.sh +++ b/scripts/e2e/onboard-docker.sh @@ -74,8 +74,14 @@ TRASH try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } // Clack/script output can include lots of control sequences; keep a larger tail and strip ANSI more robustly. if (text.length > 120000) text = text.slice(-120000); - const stripAnsi = (value) => + const normalizeScriptOutput = (value) => value + // util-linux script can emit each byte on its own CRLF-delimited line. + // Collapse those first so ANSI/control stripping works on real sequences. + .replace(/\\r?\\n/g, \"\") + .replace(/\\r/g, \"\"); + const stripAnsi = (value) => + normalizeScriptOutput(value) // OSC: ESC ] ... BEL or ESC \\ .replace(/\\x1b\\][^\\x07]*(?:\\x07|\\x1b\\\\)/g, \"\") // CSI: ESC [ ... cmd @@ -269,23 +275,24 @@ TRASH } send_channels_flow() { - # Configure channels via configure wizard. - # Prompts are interactive; notes are not. Use conservative delays to stay in sync. - # Where will the Gateway run? -> Local (default) - send $'"'"'\r'"'"' 1.2 - # Channels mode -> Configure/link (default) - send $'"'"'\r'"'"' 1.5 + # Configure channels via configure wizard. Sync on prompt text so + # keystrokes do not drift into the wrong screen when render timing changes. + wait_for_log "Where will the Gateway run?" 120 + send $'"'"'\r'"'"' 0.6 + wait_for_log "Channels" 120 + send $'"'"'\r'"'"' 0.6 # Select a channel -> Finished (last option; clack wraps on Up) - send $'"'"'\e[A\r'"'"' 2.0 + wait_for_log "Select a channel" 120 + send $'"'"'\e[A\r'"'"' 0.8 # Keep stdin open until wizard exits. - send "" 2.5 + send "" 2.0 } send_skills_flow() { - # configure --section skills still runs the configure wizard; the first prompt is gateway location. - # Avoid log-based synchronization here; clack output can fragment ANSI sequences and break matching. - send $'"'"'\r'"'"' 3.0 - wait_for_log "Configure skills now?" 120 true || true + # configure --section skills still runs the configure wizard. + wait_for_log "Where will the Gateway run?" 120 + send $'"'"'\r'"'"' 0.6 + wait_for_log "Configure skills now?" 120 send $'"'"'n\r'"'"' 0.8 send "" 2.0 } diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index 587840ec93a..632d6924099 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -8,7 +8,7 @@ echo "Building Docker image..." docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" echo "Running plugins Docker E2E..." -docker run --rm -i "$IMAGE_NAME" bash -s <<'EOF' +docker run --rm -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail if [ -f dist/index.mjs ]; then diff --git a/scripts/test-live-gateway-models-docker.sh b/scripts/test-live-gateway-models-docker.sh index f40e064910b..a3e1036171f 100755 --- a/scripts/test-live-gateway-models-docker.sh +++ b/scripts/test-live-gateway-models-docker.sh @@ -17,13 +17,20 @@ EXTERNAL_AUTH_MOUNTS=() for auth_dir in .claude .codex .minimax .qwen; do host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then - EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/home/node/"$auth_dir":ro) + EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) fi done read -r -d '' LIVE_TEST_CMD <<'EOF' || true set -euo pipefail [ -f "$HOME/.profile" ] && source "$HOME/.profile" || true +for auth_dir in .claude .codex .minimax .qwen; do + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi +done tmp_dir="$(mktemp -d)" cleanup() { rm -rf "$tmp_dir" diff --git a/scripts/test-live-models-docker.sh b/scripts/test-live-models-docker.sh index 52257cd3230..c1cec5b2740 100755 --- a/scripts/test-live-models-docker.sh +++ b/scripts/test-live-models-docker.sh @@ -17,13 +17,20 @@ EXTERNAL_AUTH_MOUNTS=() for auth_dir in .claude .codex .minimax .qwen; do host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then - EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/home/node/"$auth_dir":ro) + EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) fi done read -r -d '' LIVE_TEST_CMD <<'EOF' || true set -euo pipefail [ -f "$HOME/.profile" ] && source "$HOME/.profile" || true +for auth_dir in .claude .codex .minimax .qwen; do + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi +done tmp_dir="$(mktemp -d)" cleanup() { rm -rf "$tmp_dir" @@ -57,6 +64,9 @@ docker run --rm -t \ -e OPENCLAW_LIVE_MAX_MODELS="${OPENCLAW_LIVE_MAX_MODELS:-${CLAWDBOT_LIVE_MAX_MODELS:-48}}" \ -e OPENCLAW_LIVE_MODEL_TIMEOUT_MS="${OPENCLAW_LIVE_MODEL_TIMEOUT_MS:-${CLAWDBOT_LIVE_MODEL_TIMEOUT_MS:-}}" \ -e OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS="${OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS:-${CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS:-}}" \ + -e OPENCLAW_LIVE_GATEWAY_MODELS="${OPENCLAW_LIVE_GATEWAY_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MODELS:-}}" \ + -e OPENCLAW_LIVE_GATEWAY_PROVIDERS="${OPENCLAW_LIVE_GATEWAY_PROVIDERS:-${CLAWDBOT_LIVE_GATEWAY_PROVIDERS:-}}" \ + -e OPENCLAW_LIVE_GATEWAY_MAX_MODELS="${OPENCLAW_LIVE_GATEWAY_MAX_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MAX_MODELS:-}}" \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 303b85b72d2..eae0fab70af 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -51,4 +51,40 @@ describe("syncExternalCliCredentials", () => { }); expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined(); }); + + it("refreshes stored Codex expiry from external CLI even when the cached profile looks fresh", () => { + const staleExpiry = Date.now() + 30 * 60_000; + const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; + mocks.readCodexCliCredentialsCached.mockReturnValue({ + type: "oauth", + provider: "openai-codex", + access: "new-access-token", + refresh: "new-refresh-token", + expires: freshExpiry, + accountId: "acct_456", + }); + + const store: AuthProfileStore = { + version: 1, + profiles: { + [OPENAI_CODEX_DEFAULT_PROFILE_ID]: { + type: "oauth", + provider: "openai-codex", + access: "old-access-token", + refresh: "old-refresh-token", + expires: staleExpiry, + accountId: "acct_456", + }, + }, + }; + + const mutated = syncExternalCliCredentials(store); + + expect(mutated).toBe(true); + expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ + access: "new-access-token", + refresh: "new-refresh-token", + expires: freshExpiry, + }); + }); }); diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 7e490c97c94..ff43b586b48 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -4,13 +4,12 @@ import { readMiniMaxCliCredentialsCached, } from "../cli-credentials.js"; import { - EXTERNAL_CLI_NEAR_EXPIRY_MS, EXTERNAL_CLI_SYNC_TTL_MS, QWEN_CLI_PROFILE_ID, MINIMAX_CLI_PROFILE_ID, log, } from "./constants.js"; -import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js"; +import type { AuthProfileStore, OAuthCredential } from "./types.js"; const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default"; @@ -37,62 +36,33 @@ function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCr ); } -function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean { - if (!cred) { - return false; - } - if (cred.type !== "oauth" && cred.type !== "token") { - return false; - } - if ( - cred.provider !== "qwen-portal" && - cred.provider !== "minimax-portal" && - cred.provider !== "openai-codex" - ) { - return false; - } - if (typeof cred.expires !== "number") { - return true; - } - return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS; -} - /** Sync external CLI credentials into the store for a given provider. */ function syncExternalCliCredentialsForProvider( store: AuthProfileStore, profileId: string, provider: string, readCredentials: () => OAuthCredential | null, - now: number, options: ExternalCliSyncOptions, ): boolean { const existing = store.profiles[profileId]; - const shouldSync = - !existing || existing.provider !== provider || !isExternalProfileFresh(existing, now); - const creds = shouldSync ? readCredentials() : null; + const creds = readCredentials(); if (!creds) { return false; } const existingOAuth = existing?.type === "oauth" ? existing : undefined; - const shouldUpdate = - !existingOAuth || - existingOAuth.provider !== provider || - existingOAuth.expires <= now || - creds.expires > existingOAuth.expires; - - if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) { - store.profiles[profileId] = creds; - if (options.log !== false) { - log.info(`synced ${provider} credentials from external cli`, { - profileId, - expires: new Date(creds.expires).toISOString(), - }); - } - return true; + if (shallowEqualOAuthCredentials(existingOAuth, creds)) { + return false; } - return false; + store.profiles[profileId] = creds; + if (options.log !== false) { + log.info(`synced ${provider} credentials from external cli`, { + profileId, + expires: new Date(creds.expires).toISOString(), + }); + } + return true; } /** @@ -106,46 +76,24 @@ export function syncExternalCliCredentials( options: ExternalCliSyncOptions = {}, ): boolean { let mutated = false; - const now = Date.now(); - // Sync from Qwen Code CLI - const existingQwen = store.profiles[QWEN_CLI_PROFILE_ID]; - const shouldSyncQwen = - !existingQwen || - existingQwen.provider !== "qwen-portal" || - !isExternalProfileFresh(existingQwen, now); - const qwenCreds = shouldSyncQwen - ? readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }) - : null; - if (qwenCreds) { - const existing = store.profiles[QWEN_CLI_PROFILE_ID]; - const existingOAuth = existing?.type === "oauth" ? existing : undefined; - const shouldUpdate = - !existingOAuth || - existingOAuth.provider !== "qwen-portal" || - existingOAuth.expires <= now || - qwenCreds.expires > existingOAuth.expires; - - if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) { - store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds; - mutated = true; - if (options.log !== false) { - log.info("synced qwen credentials from qwen cli", { - profileId: QWEN_CLI_PROFILE_ID, - expires: new Date(qwenCreds.expires).toISOString(), - }); - } - } + if ( + syncExternalCliCredentialsForProvider( + store, + QWEN_CLI_PROFILE_ID, + "qwen-portal", + () => readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), + options, + ) + ) { + mutated = true; } - - // Sync from MiniMax Portal CLI if ( syncExternalCliCredentialsForProvider( store, MINIMAX_CLI_PROFILE_ID, "minimax-portal", () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), - now, options, ) ) { @@ -157,7 +105,6 @@ export function syncExternalCliCredentials( OPENAI_CODEX_DEFAULT_PROFILE_ID, "openai-codex", () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), - now, options, ) ) { diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index fcfaf21450d..53be1581b13 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -46,6 +46,12 @@ async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) { }); } +function createJwtWithExp(expSeconds: number): string { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`; +} + describe("cli credentials", () => { beforeAll(async () => { ({ @@ -229,6 +235,7 @@ describe("cli credentials", () => { it("reads Codex credentials from keychain when available", async () => { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-")); process.env.CODEX_HOME = tempHome; + const expSeconds = Math.floor(Date.parse("2026-03-23T00:48:49Z") / 1000); const accountHash = "cli|"; @@ -238,7 +245,7 @@ describe("cli credentials", () => { expect(cmd).toContain(accountHash); return JSON.stringify({ tokens: { - access_token: "keychain-access", + access_token: createJwtWithExp(expSeconds), refresh_token: "keychain-refresh", }, last_refresh: "2026-01-01T00:00:00Z", @@ -248,15 +255,17 @@ describe("cli credentials", () => { const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock }); expect(creds).toMatchObject({ - access: "keychain-access", + access: createJwtWithExp(expSeconds), refresh: "keychain-refresh", provider: "openai-codex", + expires: expSeconds * 1000, }); }); it("falls back to Codex auth.json when keychain is unavailable", async () => { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-")); process.env.CODEX_HOME = tempHome; + const expSeconds = Math.floor(Date.parse("2026-03-24T12:34:56Z") / 1000); execSyncMock.mockImplementation(() => { throw new Error("not found"); }); @@ -267,7 +276,7 @@ describe("cli credentials", () => { authPath, JSON.stringify({ tokens: { - access_token: "file-access", + access_token: createJwtWithExp(expSeconds), refresh_token: "file-refresh", }, }), @@ -277,9 +286,10 @@ describe("cli credentials", () => { const creds = readCodexCliCredentials({ execSync: execSyncMock }); expect(creds).toMatchObject({ - access: "file-access", + access: createJwtWithExp(expSeconds), refresh: "file-refresh", provider: "openai-codex", + expires: expSeconds * 1000, }); }); }); diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index 0d6d7c28c84..8ded765346a 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -153,6 +153,22 @@ function computeCodexKeychainAccount(codexHome: string) { return `cli|${hash.slice(0, 16)}`; } +function decodeJwtExpiryMs(token: string): number | null { + const parts = token.split("."); + if (parts.length < 2) { + return null; + } + try { + const payloadRaw = Buffer.from(parts[1], "base64url").toString("utf8"); + const payload = JSON.parse(payloadRaw) as { exp?: unknown }; + return typeof payload.exp === "number" && Number.isFinite(payload.exp) && payload.exp > 0 + ? payload.exp * 1000 + : null; + } catch { + return null; + } +} + function readCodexKeychainCredentials(options?: { platform?: NodeJS.Platform; execSync?: ExecSyncFn; @@ -193,9 +209,10 @@ function readCodexKeychainCredentials(options?: { typeof lastRefreshRaw === "string" || typeof lastRefreshRaw === "number" ? new Date(lastRefreshRaw).getTime() : Date.now(); - const expires = Number.isFinite(lastRefresh) + const fallbackExpiry = Number.isFinite(lastRefresh) ? lastRefresh + 60 * 60 * 1000 : Date.now() + 60 * 60 * 1000; + const expires = decodeJwtExpiryMs(accessToken) ?? fallbackExpiry; const accountId = typeof tokens?.account_id === "string" ? tokens.account_id : undefined; log.info("read codex credentials from keychain", { @@ -483,13 +500,14 @@ export function readCodexCliCredentials(options?: { return null; } - let expires: number; + let fallbackExpiry: number; try { const stat = fs.statSync(authPath); - expires = stat.mtimeMs + 60 * 60 * 1000; + fallbackExpiry = stat.mtimeMs + 60 * 60 * 1000; } catch { - expires = Date.now() + 60 * 60 * 1000; + fallbackExpiry = Date.now() + 60 * 60 * 1000; } + const expires = decodeJwtExpiryMs(accessToken) ?? fallbackExpiry; return { type: "oauth", diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index 515d2b48ce6..87cbbb6a203 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -117,6 +117,10 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean { return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in"); } +function isRefreshTokenReused(raw: string): boolean { + return /refresh_token_reused/i.test(raw); +} + function isInstructionsRequiredError(raw: string): boolean { return /instructions are required/i.test(raw); } @@ -643,6 +647,15 @@ describeLive("live models (profile keys)", () => { logProgress(`${progressLabel}: skip (rate limit)`); break; } + if ( + allowNotFoundSkip && + model.provider === "openai-codex" && + isRefreshTokenReused(message) + ) { + skipped.push({ model: id, reason: message }); + logProgress(`${progressLabel}: skip (codex refresh token reused)`); + break; + } if ( allowNotFoundSkip && model.provider === "openai-codex" && diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 6a74c98da3b..973cf952d16 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -24,7 +24,7 @@ import { shouldSuppressBuiltInModel } from "../agents/model-suppression.js"; import { ensureOpenClawModelsJson } from "../agents/models-config.js"; import { isRateLimitErrorMessage } from "../agents/pi-embedded-helpers/errors.js"; import { discoverAuthStorage, discoverModels } from "../agents/pi-model-discovery.js"; -import { loadConfig } from "../config/config.js"; +import { clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js"; import type { ModelsConfig, OpenClawConfig, ModelProviderConfig } from "../config/types.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; @@ -38,7 +38,7 @@ import { shouldRetryToolReadProbe, } from "./live-tool-probe-utils.js"; import { startGatewayServer } from "./server.js"; -import { extractPayloadText } from "./test-helpers.agent-results.js"; +import { loadSessionEntry, readSessionMessages } from "./session-utils.js"; const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); const GATEWAY_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_GATEWAY); @@ -171,6 +171,32 @@ function logProgress(message: string): void { console.log(`[live] ${message}`); } +function enterProductionEnvForLiveRun() { + const previous = { + vitest: process.env.VITEST, + nodeEnv: process.env.NODE_ENV, + }; + delete process.env.VITEST; + process.env.NODE_ENV = "production"; + return previous; +} + +function restoreProductionEnvForLiveRun(previous: { + vitest: string | undefined; + nodeEnv: string | undefined; +}) { + if (previous.vitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = previous.vitest; + } + if (previous.nodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previous.nodeEnv; + } +} + function formatFailurePreview( failures: Array<{ model: string; error: string }>, maxItems: number, @@ -319,25 +345,14 @@ async function runAnthropicRefusalProbe(params: { }): Promise { logProgress(`${params.label}: refusal-probe`); const magic = buildAnthropicRefusalToken(); - const runId = randomUUID(); - const probe = await withGatewayLiveProbeTimeout( - params.client.request( - "agent", - { - sessionKey: params.sessionKey, - idempotencyKey: `idem-${runId}-refusal`, - message: `Reply with the single word ok. Test token: ${magic}`, - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${params.label}: refusal-probe`, - ); - if (probe?.status !== "ok") { - throw new Error(`refusal probe failed: status=${String(probe?.status)}`); - } - const probeText = extractPayloadText(probe?.result); + const probeText = await requestGatewayAgentText({ + client: params.client, + sessionKey: params.sessionKey, + idempotencyKey: `idem-${randomUUID()}-refusal`, + message: `Reply with the single word ok. Test token: ${magic}`, + thinkingLevel: params.thinkingLevel, + context: `${params.label}: refusal-probe`, + }); assertNoReasoningTags({ text: probeText, model: params.modelKey, @@ -348,25 +363,14 @@ async function runAnthropicRefusalProbe(params: { throw new Error(`refusal probe missing ok: ${probeText}`); } - const followupId = randomUUID(); - const followup = await withGatewayLiveProbeTimeout( - params.client.request( - "agent", - { - sessionKey: params.sessionKey, - idempotencyKey: `idem-${followupId}-refusal-followup`, - message: "Now reply with exactly: still ok.", - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${params.label}: refusal-followup`, - ); - if (followup?.status !== "ok") { - throw new Error(`refusal followup failed: status=${String(followup?.status)}`); - } - const followupText = extractPayloadText(followup?.result); + const followupText = await requestGatewayAgentText({ + client: params.client, + sessionKey: params.sessionKey, + idempotencyKey: `idem-${randomUUID()}-refusal-followup`, + message: "Now reply with exactly: still ok.", + thinkingLevel: params.thinkingLevel, + context: `${params.label}: refusal-followup`, + }); assertNoReasoningTags({ text: followupText, model: params.modelKey, @@ -475,11 +479,6 @@ async function getFreeGatewayPort(): Promise { throw new Error("failed to acquire a free gateway port block"); } -type AgentFinalPayload = { - status?: unknown; - result?: unknown; -}; - async function connectClient(params: { url: string; token: string }) { return await new Promise((resolve, reject) => { let settled = false; @@ -513,6 +512,115 @@ async function connectClient(params: { url: string; token: string }) { }); } +function extractTranscriptMessageText(message: unknown): string { + if (!message || typeof message !== "object") { + return ""; + } + const record = message as { + text?: unknown; + content?: unknown; + }; + if (typeof record.text === "string" && record.text.trim()) { + return record.text.trim(); + } + if (typeof record.content === "string" && record.content.trim()) { + return record.content.trim(); + } + if (!Array.isArray(record.content)) { + return ""; + } + return record.content + .map((entry) => { + if (!entry || typeof entry !== "object") { + return ""; + } + const text = (entry as { text?: unknown }).text; + return typeof text === "string" && text.trim() ? text.trim() : ""; + }) + .filter(Boolean) + .join("\n") + .trim(); +} + +function readSessionAssistantTexts(sessionKey: string): string[] { + const { storePath, entry } = loadSessionEntry(sessionKey); + if (!entry?.sessionId) { + return []; + } + const messages = readSessionMessages(entry.sessionId, storePath, entry.sessionFile); + const assistantTexts: string[] = []; + for (const message of messages) { + if (!message || typeof message !== "object") { + continue; + } + const role = (message as { role?: unknown }).role; + if (role !== "assistant") { + continue; + } + assistantTexts.push(extractTranscriptMessageText(message)); + } + return assistantTexts; +} + +async function waitForSessionAssistantText(params: { + sessionKey: string; + baselineAssistantCount: number; + context: string; +}) { + const startedAt = Date.now(); + let delayMs = 50; + while (Date.now() - startedAt < GATEWAY_LIVE_PROBE_TIMEOUT_MS) { + const assistantTexts = readSessionAssistantTexts(params.sessionKey); + if (assistantTexts.length > params.baselineAssistantCount) { + const freshText = assistantTexts + .slice(params.baselineAssistantCount) + .map((text) => text.trim()) + .findLast((text) => text.length > 0); + if (freshText) { + return freshText; + } + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + delayMs = Math.min(delayMs * 2, 250); + } + throw new Error(`probe timeout after ${GATEWAY_LIVE_PROBE_TIMEOUT_MS}ms (${params.context})`); +} + +async function requestGatewayAgentText(params: { + client: GatewayClient; + sessionKey: string; + message: string; + thinkingLevel: string; + context: string; + idempotencyKey: string; + attachments?: Array<{ + mimeType: string; + fileName: string; + content: string; + }>; +}) { + const baselineAssistantCount = readSessionAssistantTexts(params.sessionKey).length; + const accepted = await withGatewayLiveProbeTimeout( + params.client.request<{ runId?: unknown; status?: unknown }>("agent", { + sessionKey: params.sessionKey, + idempotencyKey: params.idempotencyKey, + message: params.message, + thinking: params.thinkingLevel, + deliver: false, + attachments: params.attachments, + }), + `${params.context}: agent-accept`, + ); + if (accepted?.status !== "accepted") { + throw new Error(`agent status=${String(accepted?.status)}`); + } + return await waitForSessionAssistantText({ + sessionKey: params.sessionKey, + baselineAssistantCount, + context: `${params.context}: transcript-final`, + }); +} + type GatewayModelSuiteParams = { label: string; cfg: OpenClawConfig; @@ -636,6 +744,8 @@ function buildMinimaxProviderOverride(params: { } async function runGatewayModelSuite(params: GatewayModelSuiteParams) { + clearRuntimeConfigSnapshot(); + const runtimeEnv = enterProductionEnvForLiveRun(); const previous = { configPath: process.env.OPENCLAW_CONFIG_PATH, token: process.env.OPENCLAW_GATEWAY_TOKEN, @@ -793,48 +903,26 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { ); logProgress(`${progressLabel}: prompt`); - const runId = randomUUID(); - const payload = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runId}`, - message: - "Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.", - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${progressLabel}: prompt`, - ); - - if (payload?.status !== "ok") { - throw new Error(`agent status=${String(payload?.status)}`); - } - let text = extractPayloadText(payload?.result); + let text = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${randomUUID()}`, + message: + "Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.", + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: prompt`, + }); if (!text) { logProgress(`${progressLabel}: empty response, retrying`); - const retry = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${randomUUID()}-retry`, - message: - "Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.", - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${progressLabel}: prompt-retry`, - ); - if (retry?.status !== "ok") { - throw new Error(`agent status=${String(retry?.status)}`); - } - text = extractPayloadText(retry?.result); + text = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${randomUUID()}-retry`, + message: + "Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.", + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: prompt-retry`, + }); } if (!text && isGoogleishProvider(model.provider)) { logProgress(`${progressLabel}: skip (google empty response)`); @@ -881,36 +969,20 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { toolReadAttempt += 1 ) { const strictReply = toolReadAttempt > 0; - const toolProbe = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runIdTool}-tool-${toolReadAttempt + 1}`, - message: strictReply - ? "OpenClaw live tool probe (local, safe): " + - `use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` + - `Then reply with exactly: ${nonceA} ${nonceB}. No extra text.` - : "OpenClaw live tool probe (local, safe): " + - `use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` + - "Then reply with the two nonce values you read (include both).", - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${progressLabel}: tool-read`, - ); - if (toolProbe?.status !== "ok") { - if (toolReadAttempt + 1 < maxToolReadAttempts) { - logProgress( - `${progressLabel}: tool-read retry (${toolReadAttempt + 2}/${maxToolReadAttempts}) status=${String(toolProbe?.status)}`, - ); - continue; - } - throw new Error(`tool probe failed: status=${String(toolProbe?.status)}`); - } - toolText = extractPayloadText(toolProbe?.result); + toolText = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${runIdTool}-tool-${toolReadAttempt + 1}`, + message: strictReply + ? "OpenClaw live tool probe (local, safe): " + + `use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` + + `Then reply with exactly: ${nonceA} ${nonceB}. No extra text.` + : "OpenClaw live tool probe (local, safe): " + + `use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` + + "Then reply with the two nonce values you read (include both).", + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: tool-read`, + }); if ( isEmptyStreamText(toolText) && (model.provider === "minimax" || model.provider === "openai-codex") @@ -960,40 +1032,24 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { execReadAttempt += 1 ) { const strictReply = execReadAttempt > 0; - const execReadProbe = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runIdTool}-exec-read-${execReadAttempt + 1}`, - message: strictReply - ? "OpenClaw live tool probe (local, safe): " + - "use the tool named `exec` (or `Exec`) to run this command: " + - `mkdir -p "${tempDir}" && printf '%s' '${nonceC}' > "${toolWritePath}". ` + - `Then use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolWritePath}"}. ` + - `Then reply with exactly: ${nonceC}. No extra text.` - : "OpenClaw live tool probe (local, safe): " + - "use the tool named `exec` (or `Exec`) to run this command: " + - `mkdir -p "${tempDir}" && printf '%s' '${nonceC}' > "${toolWritePath}". ` + - `Then use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolWritePath}"}. ` + - "Finally reply including the nonce text you read back.", - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${progressLabel}: tool-exec`, - ); - if (execReadProbe?.status !== "ok") { - if (execReadAttempt + 1 < maxExecReadAttempts) { - logProgress( - `${progressLabel}: tool-exec retry (${execReadAttempt + 2}/${maxExecReadAttempts}) status=${String(execReadProbe?.status)}`, - ); - continue; - } - throw new Error(`exec+read probe failed: status=${String(execReadProbe?.status)}`); - } - execReadText = extractPayloadText(execReadProbe?.result); + execReadText = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${runIdTool}-exec-read-${execReadAttempt + 1}`, + message: strictReply + ? "OpenClaw live tool probe (local, safe): " + + "use the tool named `exec` (or `Exec`) to run this command: " + + `mkdir -p "${tempDir}" && printf '%s' '${nonceC}' > "${toolWritePath}". ` + + `Then use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolWritePath}"}. ` + + `Then reply with exactly: ${nonceC}. No extra text.` + : "OpenClaw live tool probe (local, safe): " + + "use the tool named `exec` (or `Exec`) to run this command: " + + `mkdir -p "${tempDir}" && printf '%s' '${nonceC}' > "${toolWritePath}". ` + + `Then use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolWritePath}"}. ` + + "Finally reply including the nonce text you read back.", + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: tool-exec`, + }); if ( isEmptyStreamText(execReadText) && (model.provider === "minimax" || model.provider === "openai-codex") @@ -1040,62 +1096,51 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { const imageBase64 = renderCatNoncePngBase64(imageCode); const runIdImage = randomUUID(); - const imageProbe = await withGatewayLiveProbeTimeout( - client.request( - "agent", + const imageText = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${runIdImage}-image`, + message: + "Look at the attached image. Reply with exactly two tokens separated by a single space: " + + "(1) the animal shown or written in the image, lowercase; " + + "(2) the code printed in the image, uppercase. No extra text.", + attachments: [ { - sessionKey, - idempotencyKey: `idem-${runIdImage}-image`, - message: - "Look at the attached image. Reply with exactly two tokens separated by a single space: " + - "(1) the animal shown or written in the image, lowercase; " + - "(2) the code printed in the image, uppercase. No extra text.", - attachments: [ - { - mimeType: "image/png", - fileName: `probe-${runIdImage}.png`, - content: imageBase64, - }, - ], - thinking: params.thinkingLevel, - deliver: false, + mimeType: "image/png", + fileName: `probe-${runIdImage}.png`, + content: imageBase64, }, - { expectFinal: true }, - ), - `${progressLabel}: image`, - ); + ], + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: image`, + }); // Best-effort: do not fail the whole live suite on flaky image handling. // (We still keep prompt + tool probes as hard checks.) - if (imageProbe?.status !== "ok") { - logProgress(`${progressLabel}: image skip (status=${String(imageProbe?.status)})`); + if ( + isEmptyStreamText(imageText) && + (model.provider === "minimax" || model.provider === "openai-codex") + ) { + logProgress(`${progressLabel}: image skip (${model.provider} empty response)`); } else { - const imageText = extractPayloadText(imageProbe?.result); - if ( - isEmptyStreamText(imageText) && - (model.provider === "minimax" || model.provider === "openai-codex") - ) { - logProgress(`${progressLabel}: image skip (${model.provider} empty response)`); + assertNoReasoningTags({ + text: imageText, + model: modelKey, + phase: "image", + label: params.label, + }); + if (!/\bcat\b/i.test(imageText)) { + logProgress(`${progressLabel}: image skip (missing 'cat')`); } else { - assertNoReasoningTags({ - text: imageText, - model: modelKey, - phase: "image", - label: params.label, - }); - if (!/\bcat\b/i.test(imageText)) { - logProgress(`${progressLabel}: image skip (missing 'cat')`); - } else { - const candidates = imageText.toUpperCase().match(/[A-Z0-9]{6,20}/g) ?? []; - const bestDistance = candidates.reduce((best, cand) => { - if (Math.abs(cand.length - imageCode.length) > 2) { - return best; - } - return Math.min(best, editDistance(cand, imageCode)); - }, Number.POSITIVE_INFINITY); - // OCR / image-read flake: allow a small edit distance, but still require the "cat" token above. - if (!(bestDistance <= 3)) { - logProgress(`${progressLabel}: image skip (code mismatch)`); + const candidates = imageText.toUpperCase().match(/[A-Z0-9]{6,20}/g) ?? []; + const bestDistance = candidates.reduce((best, cand) => { + if (Math.abs(cand.length - imageCode.length) > 2) { + return best; } + return Math.min(best, editDistance(cand, imageCode)); + }, Number.POSITIVE_INFINITY); + // OCR / image-read flake: allow a small edit distance, but still require the "cat" token above. + if (!(bestDistance <= 3)) { + logProgress(`${progressLabel}: image skip (code mismatch)`); } } } @@ -1108,24 +1153,14 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { ) { logProgress(`${progressLabel}: tool-only regression`); const runId2 = randomUUID(); - const first = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runId2}-1`, - message: `Call the tool named \`read\` (or \`Read\`) on "${toolProbePath}". Do not write any other text.`, - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${progressLabel}: tool-only-regression-first`, - ); - if (first?.status !== "ok") { - throw new Error(`tool-only turn failed: status=${String(first?.status)}`); - } - const firstText = extractPayloadText(first?.result); + const firstText = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${runId2}-1`, + message: `Call the tool named \`read\` (or \`Read\`) on "${toolProbePath}". Do not write any other text.`, + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: tool-only-regression-first`, + }); assertNoReasoningTags({ text: firstText, model: modelKey, @@ -1133,24 +1168,14 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { label: params.label, }); - const second = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runId2}-2`, - message: `Now answer: what are the values of nonceA and nonceB in "${toolProbePath}"? Reply with exactly: ${nonceA} ${nonceB}.`, - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${progressLabel}: tool-only-regression-second`, - ); - if (second?.status !== "ok") { - throw new Error(`post-tool message failed: status=${String(second?.status)}`); - } - const reply = extractPayloadText(second?.result); + const reply = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${runId2}-2`, + message: `Now answer: what are the values of nonceA and nonceB in "${toolProbePath}"? Reply with exactly: ${nonceA} ${nonceB}.`, + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: tool-only-regression-second`, + }); assertNoReasoningTags({ text: reply, model: modelKey, @@ -1290,6 +1315,8 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { logProgress(`[${params.label}] skipped all models (missing profiles)`); } } finally { + clearRuntimeConfigSnapshot(); + restoreProductionEnvForLiveRun(runtimeEnv); client.stop(); await server.close({ reason: "live test complete" }); await fs.rm(toolProbePath, { force: true }); @@ -1317,6 +1344,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { it( "runs meaningful prompts across models with available keys", async () => { + clearRuntimeConfigSnapshot(); const cfg = loadConfig(); await ensureOpenClawModelsJson(cfg); @@ -1422,6 +1450,8 @@ describeLive("gateway live (dev agent, profile keys)", () => { if (!ZAI_FALLBACK) { return; } + clearRuntimeConfigSnapshot(); + const runtimeEnv = enterProductionEnvForLiveRun(); const previous = { configPath: process.env.OPENCLAW_CONFIG_PATH, token: process.env.OPENCLAW_GATEWAY_TOKEN, @@ -1520,27 +1550,16 @@ describeLive("gateway live (dev agent, profile keys)", () => { "zai-fallback: sessions-reset", ); - const runId = randomUUID(); - const toolProbe = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runId}-tool`, - message: - `Call the tool named \`read\` (or \`Read\` if \`read\` is unavailable) with JSON arguments {"path":"${toolProbePath}"}. ` + - `Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`, - thinking: THINKING_LEVEL, - deliver: false, - }, - { expectFinal: true }, - ), - "zai-fallback: tool-probe", - ); - if (toolProbe?.status !== "ok") { - throw new Error(`anthropic tool probe failed: status=${String(toolProbe?.status)}`); - } - const toolText = extractPayloadText(toolProbe?.result); + const toolText = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${randomUUID()}-tool`, + message: + `Call the tool named \`read\` (or \`Read\` if \`read\` is unavailable) with JSON arguments {"path":"${toolProbePath}"}. ` + + `Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`, + thinkingLevel: THINKING_LEVEL, + context: "zai-fallback: tool-probe", + }); assertNoReasoningTags({ text: toolText, model: "anthropic/claude-opus-4-5", @@ -1559,27 +1578,16 @@ describeLive("gateway live (dev agent, profile keys)", () => { "zai-fallback: sessions-patch-zai", ); - const followupId = randomUUID(); - const followup = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${followupId}-followup`, - message: - `What are the values of nonceA and nonceB in "${toolProbePath}"? ` + - `Reply with exactly: ${nonceA} ${nonceB}.`, - thinking: THINKING_LEVEL, - deliver: false, - }, - { expectFinal: true }, - ), - "zai-fallback: followup", - ); - if (followup?.status !== "ok") { - throw new Error(`zai followup failed: status=${String(followup?.status)}`); - } - const followupText = extractPayloadText(followup?.result); + const followupText = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${randomUUID()}-followup`, + message: + `What are the values of nonceA and nonceB in "${toolProbePath}"? ` + + `Reply with exactly: ${nonceA} ${nonceB}.`, + thinkingLevel: THINKING_LEVEL, + context: "zai-fallback: followup", + }); assertNoReasoningTags({ text: followupText, model: "zai/glm-4.7", @@ -1590,6 +1598,8 @@ describeLive("gateway live (dev agent, profile keys)", () => { throw new Error(`zai followup missing nonce: ${followupText}`); } } finally { + clearRuntimeConfigSnapshot(); + restoreProductionEnvForLiveRun(runtimeEnv); client.stop(); await server.close({ reason: "live test complete" }); await fs.rm(toolProbePath, { force: true }); From fe4368cbca63d5bd177ddff8fdb3a46a0419738e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:16:39 +0000 Subject: [PATCH 029/128] fix: align thinking defaults and plugin sdk exports --- src/agents/model-selection.ts | 2 +- src/auto-reply/thinking.shared.ts | 10 ++++++++++ src/gateway/gateway-cli-backend.live.test.ts | 4 +++- src/plugin-sdk/index.ts | 6 +++--- src/plugin-sdk/ollama-setup.ts | 2 +- src/plugin-sdk/provider-setup.ts | 6 +++--- src/plugin-sdk/self-hosted-provider-setup.ts | 2 +- 7 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 7cdc52e641c..acc29a32bf9 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -1,4 +1,4 @@ -import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.js"; +import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.shared.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentModelFallbackValues, diff --git a/src/auto-reply/thinking.shared.ts b/src/auto-reply/thinking.shared.ts index bbde5b90ce5..7487928eac3 100644 --- a/src/auto-reply/thinking.shared.ts +++ b/src/auto-reply/thinking.shared.ts @@ -12,6 +12,8 @@ export type ThinkingCatalogEntry = { }; const BASE_THINKING_LEVELS: ThinkLevel[] = ["off", "minimal", "low", "medium", "high", "adaptive"]; +const ANTHROPIC_CLAUDE_46_MODEL_RE = /^claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; +const AMAZON_BEDROCK_CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; export function normalizeProviderId(provider?: string | null): string { if (!provider) { @@ -101,6 +103,14 @@ export function resolveThinkingDefaultForModel(params: { model: string; catalog?: ThinkingCatalogEntry[]; }): ThinkLevel { + const normalizedProvider = normalizeProviderId(params.provider); + const modelId = params.model.trim(); + if (normalizedProvider === "anthropic" && ANTHROPIC_CLAUDE_46_MODEL_RE.test(modelId)) { + return "adaptive"; + } + if (normalizedProvider === "amazon-bedrock" && AMAZON_BEDROCK_CLAUDE_46_MODEL_RE.test(modelId)) { + return "adaptive"; + } const candidate = params.catalog?.find( (entry) => entry.provider === params.provider && entry.id === params.model, ); diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index b0426c59175..d0d313cc455 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { parseModelRef } from "../agents/model-selection.js"; -import { loadConfig } from "../config/config.js"; +import { clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js"; import { GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -166,6 +166,7 @@ async function connectClient(params: { url: string; token: string }) { describeLive("gateway live (cli backend)", () => { it("runs the agent pipeline against the local CLI backend", async () => { + clearRuntimeConfigSnapshot(); const previous = { configPath: process.env.OPENCLAW_CONFIG_PATH, token: process.env.OPENCLAW_GATEWAY_TOKEN, @@ -384,6 +385,7 @@ describeLive("gateway live (cli backend)", () => { } } } finally { + clearRuntimeConfigSnapshot(); client.stop(); await server.close(); await fs.rm(tempDir, { recursive: true, force: true }); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 07b51661d2d..1e78ee1c7e2 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -797,21 +797,21 @@ export { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_COST, SELF_HOSTED_DEFAULT_MAX_TOKENS, -} from "../commands/self-hosted-provider-setup.js"; +} from "../commands/self-hosted-provider-setup.ts"; export { OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL, configureOllamaNonInteractive, ensureOllamaModelPulled, promptAndConfigureOllama, -} from "../commands/ollama-setup.js"; +} from "../commands/ollama-setup.ts"; export { VLLM_DEFAULT_BASE_URL, VLLM_DEFAULT_CONTEXT_WINDOW, VLLM_DEFAULT_COST, VLLM_DEFAULT_MAX_TOKENS, promptAndConfigureVllm, -} from "../commands/vllm-setup.js"; +} from "../commands/vllm-setup.ts"; export { buildOllamaProvider, buildSglangProvider, diff --git a/src/plugin-sdk/ollama-setup.ts b/src/plugin-sdk/ollama-setup.ts index 5b6fd732774..fa8c9032dda 100644 --- a/src/plugin-sdk/ollama-setup.ts +++ b/src/plugin-sdk/ollama-setup.ts @@ -12,6 +12,6 @@ export { configureOllamaNonInteractive, ensureOllamaModelPulled, promptAndConfigureOllama, -} from "../commands/ollama-setup.js"; +} from "../commands/ollama-setup.ts"; 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 index 6569c36a324..4489c8ae34d 100644 --- a/src/plugin-sdk/provider-setup.ts +++ b/src/plugin-sdk/provider-setup.ts @@ -15,21 +15,21 @@ export { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_COST, SELF_HOSTED_DEFAULT_MAX_TOKENS, -} from "../commands/self-hosted-provider-setup.js"; +} from "../commands/self-hosted-provider-setup.ts"; export { OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL, configureOllamaNonInteractive, ensureOllamaModelPulled, promptAndConfigureOllama, -} from "../commands/ollama-setup.js"; +} from "../commands/ollama-setup.ts"; export { VLLM_DEFAULT_BASE_URL, VLLM_DEFAULT_CONTEXT_WINDOW, VLLM_DEFAULT_COST, VLLM_DEFAULT_MAX_TOKENS, promptAndConfigureVllm, -} from "../commands/vllm-setup.js"; +} from "../commands/vllm-setup.ts"; export { buildOllamaProvider, buildSglangProvider, diff --git a/src/plugin-sdk/self-hosted-provider-setup.ts b/src/plugin-sdk/self-hosted-provider-setup.ts index 950bbbb953e..60be2852a2d 100644 --- a/src/plugin-sdk/self-hosted-provider-setup.ts +++ b/src/plugin-sdk/self-hosted-provider-setup.ts @@ -15,7 +15,7 @@ export { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_COST, SELF_HOSTED_DEFAULT_MAX_TOKENS, -} from "../commands/self-hosted-provider-setup.js"; +} from "../commands/self-hosted-provider-setup.ts"; export { buildSglangProvider, From 683be73d54dec931268f36b3d6c31aeba649dbb1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 18:40:52 -0700 Subject: [PATCH 030/128] refactor: point onboarding provider config to extensions --- src/agents/models-config.providers.ts | 28 ++++++++++++++---------- src/commands/onboard-auth.config-core.ts | 18 ++++++++------- src/commands/onboard-auth.models.ts | 2 +- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 264cb402b47..19ce478b2f4 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1,3 +1,8 @@ +import { + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, +} from "../../extensions/qianfan/provider-catalog.js"; +import { XIAOMI_DEFAULT_MODEL_ID } from "../../extensions/xiaomi/provider-catalog.js"; import type { OpenClawConfig } from "../config/config.js"; import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; import { isRecord } from "../utils.js"; @@ -6,24 +11,23 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles import { discoverBedrockModels } from "./bedrock-discovery.js"; import { normalizeGoogleModelId } from "./model-id-normalization.js"; import { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; -import { - QIANFAN_BASE_URL, - QIANFAN_DEFAULT_MODEL_ID, - XIAOMI_DEFAULT_MODEL_ID, -} from "./models-config.providers.static.js"; +export { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js"; +export { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js"; export { - buildKimiCodingProvider, - buildKilocodeProvider, - buildNvidiaProvider, - buildModelStudioProvider, - buildQianfanProvider, - buildXiaomiProvider, MODELSTUDIO_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_ID, + buildModelStudioProvider, +} from "../../extensions/modelstudio/provider-catalog.js"; +export { buildNvidiaProvider } from "../../extensions/nvidia/provider-catalog.js"; +export { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID, + buildQianfanProvider, +} from "../../extensions/qianfan/provider-catalog.js"; +export { XIAOMI_DEFAULT_MODEL_ID, -} from "./models-config.providers.static.js"; + buildXiaomiProvider, +} from "../../extensions/xiaomi/provider-catalog.js"; import { groupPluginDiscoveryProvidersByOrder, normalizePluginDiscoveryResult, diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index c939a2cb99d..9064f5bfc58 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -1,16 +1,18 @@ +import { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js"; +import { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js"; +import { + QIANFAN_DEFAULT_MODEL_ID, + buildQianfanProvider, +} from "../../extensions/qianfan/provider-catalog.js"; +import { + XIAOMI_DEFAULT_MODEL_ID, + buildXiaomiProvider, +} from "../../extensions/xiaomi/provider-catalog.js"; import { buildHuggingfaceModelDefinition, HUGGINGFACE_BASE_URL, HUGGINGFACE_MODEL_CATALOG, } from "../agents/huggingface-models.js"; -import { - buildKilocodeProvider, - buildKimiCodingProvider, - buildQianfanProvider, - buildXiaomiProvider, - QIANFAN_DEFAULT_MODEL_ID, - XIAOMI_DEFAULT_MODEL_ID, -} from "../agents/models-config.providers.static.js"; import { buildSyntheticModelDefinition, SYNTHETIC_BASE_URL, diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts index 383121b5700..e9524952750 100644 --- a/src/commands/onboard-auth.models.ts +++ b/src/commands/onboard-auth.models.ts @@ -1,7 +1,7 @@ import { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID, -} from "../agents/models-config.providers.static.js"; +} from "../../extensions/qianfan/provider-catalog.js"; import type { ModelDefinitionConfig } from "../config/types.js"; import { KILOCODE_DEFAULT_CONTEXT_WINDOW, From 5a763ac57b9666d74de5f6564869bfae483a0805 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 18:43:00 -0700 Subject: [PATCH 031/128] fix: restore check after upstream type drift --- .../onboard-non-interactive.provider-auth.test.ts | 7 ++++--- src/infra/gaxios-fetch-compat.test.ts | 11 ++++++----- src/infra/gaxios-fetch-compat.ts | 7 +++++-- src/plugin-sdk-internal/setup.ts | 1 + src/plugin-sdk/telegram.ts | 1 + 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index abf8362d694..66050fe6f62 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -22,6 +22,7 @@ type OnboardEnv = { configPath: string; runtime: NonInteractiveRuntime; }; +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => {})); @@ -61,7 +62,7 @@ type ProviderAuthConfigSnapshot = { }; }; -function createZaiFetchMock(responses: Record): typeof fetch { +function createZaiFetchMock(responses: Record): FetchLike { return vi.fn(async (input, init) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : ""; const parsedBody = @@ -77,12 +78,12 @@ function createZaiFetchMock(responses: Record): typeof fetch { headers: { "content-type": "application/json" }, }, ); - }) as typeof fetch; + }); } async function withZaiProbeFetch( responses: Record, - run: (fetchMock: typeof fetch) => Promise, + run: (fetchMock: FetchLike) => Promise, ): Promise { const originalVitest = process.env.VITEST; delete process.env.VITEST; diff --git a/src/infra/gaxios-fetch-compat.test.ts b/src/infra/gaxios-fetch-compat.test.ts index b3cbf68a1ab..7d4c0dd402a 100644 --- a/src/infra/gaxios-fetch-compat.test.ts +++ b/src/infra/gaxios-fetch-compat.test.ts @@ -3,6 +3,7 @@ import { ProxyAgent } from "undici"; import { afterEach, describe, expect, it, vi } from "vitest"; const TEST_GAXIOS_CONSTRUCTOR_OVERRIDE = "__OPENCLAW_TEST_GAXIOS_CONSTRUCTOR__"; +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; describe("gaxios fetch compat", () => { afterEach(() => { @@ -14,14 +15,14 @@ describe("gaxios fetch compat", () => { it("uses native fetch without defining window or importing node-fetch", async () => { type MockRequestConfig = RequestInit & { - fetchImplementation?: typeof fetch; + fetchImplementation?: FetchLike; responseType?: string; url: string; }; let MockGaxiosCtor!: new () => { request(config: MockRequestConfig): Promise<{ data: string } & object>; }; - const fetchMock = vi.fn(async () => { + const fetchMock = vi.fn(async () => { return new Response("ok", { headers: { "content-type": "text/plain" }, status: 200, @@ -64,14 +65,14 @@ describe("gaxios fetch compat", () => { 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()); + 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); + expect((globalThis as { window?: { fetch?: FetchLike } }).window?.fetch).toBe(fetch); await expect(installGaxiosFetchCompat()).resolves.toBeUndefined(); } finally { Reflect.deleteProperty(globalThis as object, "window"); @@ -82,7 +83,7 @@ describe("gaxios fetch compat", () => { }); it("translates proxy agents into undici dispatchers for native fetch", async () => { - const fetchMock = vi.fn(async () => { + const fetchMock = vi.fn(async () => { return new Response("ok", { headers: { "content-type": "text/plain" }, status: 200, diff --git a/src/infra/gaxios-fetch-compat.ts b/src/infra/gaxios-fetch-compat.ts index 6f9d34bf7af..0d5c0684090 100644 --- a/src/infra/gaxios-fetch-compat.ts +++ b/src/infra/gaxios-fetch-compat.ts @@ -7,12 +7,13 @@ import { Agent as UndiciAgent, ProxyAgent } from "undici"; type ProxyRule = RegExp | URL | string; type TlsCert = ConnectionOptions["cert"]; type TlsKey = ConnectionOptions["key"]; +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; type GaxiosFetchRequestInit = RequestInit & { agent?: unknown; cert?: TlsCert; dispatcher?: Dispatcher; - fetchImplementation?: typeof fetch; + fetchImplementation?: FetchLike; key?: TlsKey; noProxy?: ProxyRule[]; proxy?: string | URL; @@ -240,7 +241,9 @@ function installLegacyWindowFetchShim(): void { (globalThis as Record).window = { fetch: globalThis.fetch }; } -export function createGaxiosCompatFetch(baseFetch: typeof fetch = globalThis.fetch): typeof fetch { +export function createGaxiosCompatFetch( + baseFetch: FetchLike = globalThis.fetch.bind(globalThis), +): FetchLike { return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const gaxiosInit = (init ?? {}) as GaxiosFetchRequestInit; const requestUrl = diff --git a/src/plugin-sdk-internal/setup.ts b/src/plugin-sdk-internal/setup.ts index 6caf9253e14..c035d40376a 100644 --- a/src/plugin-sdk-internal/setup.ts +++ b/src/plugin-sdk-internal/setup.ts @@ -1,4 +1,5 @@ export type { OpenClawConfig } from "../config/config.js"; +export type { DmPolicy } from "../config/types.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"; diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 3e1275c1425..6551baffe87 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -2,6 +2,7 @@ export type { ChannelAccountSnapshot, ChannelGatewayContext, ChannelMessageActionAdapter, + ChannelPlugin, } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; From 7df0ced8ac0929611879bdfdd1716b6dd51affd6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 19:51:55 -0700 Subject: [PATCH 032/128] refactor: move provider onboarding into extensions --- extensions/huggingface/index.ts | 5 +- extensions/huggingface/onboard.ts | 35 ++ extensions/kimi-coding/index.ts | 2 +- extensions/kimi-coding/onboard.ts | 38 ++ extensions/mistral/index.ts | 2 +- extensions/mistral/onboard.ts | 33 ++ extensions/moonshot/index.ts | 10 +- extensions/moonshot/onboard.ts | 60 ++++ extensions/openrouter/index.ts | 5 +- extensions/openrouter/onboard.ts | 30 ++ extensions/qianfan/index.ts | 2 +- extensions/qianfan/onboard.ts | 48 +++ extensions/synthetic/index.ts | 5 +- extensions/synthetic/onboard.ts | 36 ++ extensions/together/index.ts | 5 +- extensions/together/onboard.ts | 35 ++ extensions/venice/index.ts | 2 +- extensions/venice/onboard.ts | 33 ++ extensions/xai/index.ts | 2 +- extensions/xai/onboard.ts | 33 ++ extensions/xiaomi/index.ts | 2 +- extensions/xiaomi/onboard.ts | 30 ++ src/commands/onboard-auth.config-core.ts | 430 +++-------------------- src/commands/onboard-auth.ts | 17 +- 24 files changed, 484 insertions(+), 416 deletions(-) create mode 100644 extensions/huggingface/onboard.ts create mode 100644 extensions/kimi-coding/onboard.ts create mode 100644 extensions/mistral/onboard.ts create mode 100644 extensions/moonshot/onboard.ts create mode 100644 extensions/openrouter/onboard.ts create mode 100644 extensions/qianfan/onboard.ts create mode 100644 extensions/synthetic/onboard.ts create mode 100644 extensions/together/onboard.ts create mode 100644 extensions/venice/onboard.ts create mode 100644 extensions/xai/onboard.ts create mode 100644 extensions/xiaomi/onboard.ts diff --git a/extensions/huggingface/index.ts b/extensions/huggingface/index.ts index 63598ce0236..433223bf268 100644 --- a/extensions/huggingface/index.ts +++ b/extensions/huggingface/index.ts @@ -1,9 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { - applyHuggingfaceConfig, - HUGGINGFACE_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyHuggingfaceConfig, HUGGINGFACE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildHuggingfaceProvider } from "./provider-catalog.js"; const PROVIDER_ID = "huggingface"; diff --git a/extensions/huggingface/onboard.ts b/extensions/huggingface/onboard.ts new file mode 100644 index 00000000000..22493f87f0b --- /dev/null +++ b/extensions/huggingface/onboard.ts @@ -0,0 +1,35 @@ +import { + buildHuggingfaceModelDefinition, + HUGGINGFACE_BASE_URL, + HUGGINGFACE_MODEL_CATALOG, +} from "../../src/agents/huggingface-models.js"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export const HUGGINGFACE_DEFAULT_MODEL_REF = "huggingface/deepseek-ai/DeepSeek-R1"; + +export function applyHuggingfaceProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[HUGGINGFACE_DEFAULT_MODEL_REF] = { + ...models[HUGGINGFACE_DEFAULT_MODEL_REF], + alias: models[HUGGINGFACE_DEFAULT_MODEL_REF]?.alias ?? "Hugging Face", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "huggingface", + api: "openai-completions", + baseUrl: HUGGINGFACE_BASE_URL, + catalogModels: HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition), + }); +} + +export function applyHuggingfaceConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyHuggingfaceProviderConfig(cfg), + HUGGINGFACE_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index 42853a16c0c..ed193fe714b 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -1,7 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -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 { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "./onboard.js"; import { buildKimiCodingProvider } from "./provider-catalog.js"; const PROVIDER_ID = "kimi-coding"; diff --git a/extensions/kimi-coding/onboard.ts b/extensions/kimi-coding/onboard.ts new file mode 100644 index 00000000000..866780ddaaa --- /dev/null +++ b/extensions/kimi-coding/onboard.ts @@ -0,0 +1,38 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModel, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { + buildKimiCodingProvider, + KIMI_CODING_BASE_URL, + KIMI_CODING_DEFAULT_MODEL_ID, +} from "./provider-catalog.js"; + +export const KIMI_CODING_MODEL_REF = `kimi-coding/${KIMI_CODING_DEFAULT_MODEL_ID}`; + +export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[KIMI_CODING_MODEL_REF] = { + ...models[KIMI_CODING_MODEL_REF], + alias: models[KIMI_CODING_MODEL_REF]?.alias ?? "Kimi for Coding", + }; + + const defaultModel = buildKimiCodingProvider().models[0]; + if (!defaultModel) { + return cfg; + } + + return applyProviderConfigWithDefaultModel(cfg, { + agentModels: models, + providerId: "kimi-coding", + api: "anthropic-messages", + baseUrl: KIMI_CODING_BASE_URL, + defaultModel, + defaultModelId: KIMI_CODING_DEFAULT_MODEL_ID, + }); +} + +export function applyKimiCodeConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyKimiCodeProviderConfig(cfg), KIMI_CODING_MODEL_REF); +} diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index 56e24f8560c..10211480a29 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "mistral"; diff --git a/extensions/mistral/onboard.ts b/extensions/mistral/onboard.ts new file mode 100644 index 00000000000..28a6d12ce17 --- /dev/null +++ b/extensions/mistral/onboard.ts @@ -0,0 +1,33 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModel, +} from "../../src/commands/onboard-auth.config-shared.js"; +import { + buildMistralModelDefinition, + MISTRAL_BASE_URL, + MISTRAL_DEFAULT_MODEL_ID, +} from "../../src/commands/onboard-auth.models.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export const MISTRAL_DEFAULT_MODEL_REF = `mistral/${MISTRAL_DEFAULT_MODEL_ID}`; + +export function applyMistralProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[MISTRAL_DEFAULT_MODEL_REF] = { + ...models[MISTRAL_DEFAULT_MODEL_REF], + alias: models[MISTRAL_DEFAULT_MODEL_REF]?.alias ?? "Mistral", + }; + + return applyProviderConfigWithDefaultModel(cfg, { + agentModels: models, + providerId: "mistral", + api: "openai-completions", + baseUrl: MISTRAL_BASE_URL, + defaultModel: buildMistralModelDefinition(), + defaultModelId: MISTRAL_DEFAULT_MODEL_ID, + }); +} + +export function applyMistralConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyMistralProviderConfig(cfg), MISTRAL_DEFAULT_MODEL_REF); +} diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 94e01d3a069..0b92216bdd7 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -7,14 +7,14 @@ import { getScopedCredentialValue, setScopedCredentialValue, } from "../../src/agents/tools/web-search-plugin-factory.js"; -import { - applyMoonshotConfig, - applyMoonshotConfigCn, -} from "../../src/commands/onboard-auth.config-core.js"; -import { MOONSHOT_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.models.js"; 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 { + applyMoonshotConfig, + applyMoonshotConfigCn, + MOONSHOT_DEFAULT_MODEL_REF, +} from "./onboard.js"; import { buildMoonshotProvider } from "./provider-catalog.js"; const PROVIDER_ID = "moonshot"; diff --git a/extensions/moonshot/onboard.ts b/extensions/moonshot/onboard.ts new file mode 100644 index 00000000000..57459b724ce --- /dev/null +++ b/extensions/moonshot/onboard.ts @@ -0,0 +1,60 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModel, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { + buildMoonshotProvider, + MOONSHOT_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, +} from "./provider-catalog.js"; + +export const MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1"; +export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`; + +export function applyMoonshotProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyMoonshotProviderConfigWithBaseUrl(cfg, MOONSHOT_BASE_URL); +} + +export function applyMoonshotProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig { + return applyMoonshotProviderConfigWithBaseUrl(cfg, MOONSHOT_CN_BASE_URL); +} + +function applyMoonshotProviderConfigWithBaseUrl( + cfg: OpenClawConfig, + baseUrl: string, +): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[MOONSHOT_DEFAULT_MODEL_REF] = { + ...models[MOONSHOT_DEFAULT_MODEL_REF], + alias: models[MOONSHOT_DEFAULT_MODEL_REF]?.alias ?? "Kimi", + }; + + const defaultModel = buildMoonshotProvider().models[0]; + if (!defaultModel) { + return cfg; + } + + return applyProviderConfigWithDefaultModel(cfg, { + agentModels: models, + providerId: "moonshot", + api: "openai-completions", + baseUrl, + defaultModel, + defaultModelId: MOONSHOT_DEFAULT_MODEL_ID, + }); +} + +export function applyMoonshotConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyMoonshotProviderConfig(cfg), + MOONSHOT_DEFAULT_MODEL_REF, + ); +} + +export function applyMoonshotConfigCn(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyMoonshotProviderConfigCn(cfg), + MOONSHOT_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index 0fdac10ea0e..2246424787a 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -15,11 +15,8 @@ import { createOpenRouterWrapper, isProxyReasoningUnsupported, } from "../../src/agents/pi-embedded-runner/proxy-stream-wrappers.js"; -import { - applyOpenrouterConfig, - OPENROUTER_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildOpenrouterProvider } from "./provider-catalog.js"; const PROVIDER_ID = "openrouter"; diff --git a/extensions/openrouter/onboard.ts b/extensions/openrouter/onboard.ts new file mode 100644 index 00000000000..03ec7bf86bc --- /dev/null +++ b/extensions/openrouter/onboard.ts @@ -0,0 +1,30 @@ +import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto"; + +export function applyOpenrouterProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[OPENROUTER_DEFAULT_MODEL_REF] = { + ...models[OPENROUTER_DEFAULT_MODEL_REF], + alias: models[OPENROUTER_DEFAULT_MODEL_REF]?.alias ?? "OpenRouter", + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; +} + +export function applyOpenrouterConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyOpenrouterProviderConfig(cfg), + OPENROUTER_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/qianfan/index.ts b/extensions/qianfan/index.ts index 6ce5bd21008..6840c8623fa 100644 --- a/extensions/qianfan/index.ts +++ b/extensions/qianfan/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildQianfanProvider } from "./provider-catalog.js"; const PROVIDER_ID = "qianfan"; diff --git a/extensions/qianfan/onboard.ts b/extensions/qianfan/onboard.ts new file mode 100644 index 00000000000..6df59e49a40 --- /dev/null +++ b/extensions/qianfan/onboard.ts @@ -0,0 +1,48 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModels, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import type { ModelApi } from "../../src/config/types.models.js"; +import { + buildQianfanProvider, + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, +} from "./provider-catalog.js"; + +export const QIANFAN_DEFAULT_MODEL_REF = `qianfan/${QIANFAN_DEFAULT_MODEL_ID}`; + +export function applyQianfanProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[QIANFAN_DEFAULT_MODEL_REF] = { + ...models[QIANFAN_DEFAULT_MODEL_REF], + alias: models[QIANFAN_DEFAULT_MODEL_REF]?.alias ?? "QIANFAN", + }; + const defaultProvider = buildQianfanProvider(); + const existingProvider = cfg.models?.providers?.qianfan as + | { + baseUrl?: unknown; + api?: unknown; + } + | undefined; + const existingBaseUrl = + typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : ""; + const resolvedBaseUrl = existingBaseUrl || QIANFAN_BASE_URL; + const resolvedApi = + typeof existingProvider?.api === "string" + ? (existingProvider.api as ModelApi) + : "openai-completions"; + + return applyProviderConfigWithDefaultModels(cfg, { + agentModels: models, + providerId: "qianfan", + api: resolvedApi, + baseUrl: resolvedBaseUrl, + defaultModels: defaultProvider.models ?? [], + defaultModelId: QIANFAN_DEFAULT_MODEL_ID, + }); +} + +export function applyQianfanConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyQianfanProviderConfig(cfg), QIANFAN_DEFAULT_MODEL_REF); +} diff --git a/extensions/synthetic/index.ts b/extensions/synthetic/index.ts index 6e0d6072bf1..9a100df052d 100644 --- a/extensions/synthetic/index.ts +++ b/extensions/synthetic/index.ts @@ -1,9 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { - applySyntheticConfig, - SYNTHETIC_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applySyntheticConfig, SYNTHETIC_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSyntheticProvider } from "./provider-catalog.js"; const PROVIDER_ID = "synthetic"; diff --git a/extensions/synthetic/onboard.ts b/extensions/synthetic/onboard.ts new file mode 100644 index 00000000000..34199d4db2b --- /dev/null +++ b/extensions/synthetic/onboard.ts @@ -0,0 +1,36 @@ +import { + buildSyntheticModelDefinition, + SYNTHETIC_BASE_URL, + SYNTHETIC_DEFAULT_MODEL_REF, + SYNTHETIC_MODEL_CATALOG, +} from "../../src/agents/synthetic-models.js"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export { SYNTHETIC_DEFAULT_MODEL_REF }; + +export function applySyntheticProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[SYNTHETIC_DEFAULT_MODEL_REF] = { + ...models[SYNTHETIC_DEFAULT_MODEL_REF], + alias: models[SYNTHETIC_DEFAULT_MODEL_REF]?.alias ?? "MiniMax M2.5", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "synthetic", + api: "anthropic-messages", + baseUrl: SYNTHETIC_BASE_URL, + catalogModels: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition), + }); +} + +export function applySyntheticConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applySyntheticProviderConfig(cfg), + SYNTHETIC_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/together/index.ts b/extensions/together/index.ts index cb4113b6009..9a3a8df330c 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -1,9 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { - applyTogetherConfig, - TOGETHER_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyTogetherConfig, TOGETHER_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildTogetherProvider } from "./provider-catalog.js"; const PROVIDER_ID = "together"; diff --git a/extensions/together/onboard.ts b/extensions/together/onboard.ts new file mode 100644 index 00000000000..a540401e01a --- /dev/null +++ b/extensions/together/onboard.ts @@ -0,0 +1,35 @@ +import { + buildTogetherModelDefinition, + TOGETHER_BASE_URL, + TOGETHER_MODEL_CATALOG, +} from "../../src/agents/together-models.js"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export const TOGETHER_DEFAULT_MODEL_REF = "together/moonshotai/Kimi-K2.5"; + +export function applyTogetherProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[TOGETHER_DEFAULT_MODEL_REF] = { + ...models[TOGETHER_DEFAULT_MODEL_REF], + alias: models[TOGETHER_DEFAULT_MODEL_REF]?.alias ?? "Together AI", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "together", + api: "openai-completions", + baseUrl: TOGETHER_BASE_URL, + catalogModels: TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition), + }); +} + +export function applyTogetherConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyTogetherProviderConfig(cfg), + TOGETHER_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index 8d3f377d130..90b36a59f94 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildVeniceProvider } from "./provider-catalog.js"; const PROVIDER_ID = "venice"; diff --git a/extensions/venice/onboard.ts b/extensions/venice/onboard.ts new file mode 100644 index 00000000000..fbd535d6264 --- /dev/null +++ b/extensions/venice/onboard.ts @@ -0,0 +1,33 @@ +import { + buildVeniceModelDefinition, + VENICE_BASE_URL, + VENICE_DEFAULT_MODEL_REF, + VENICE_MODEL_CATALOG, +} from "../../src/agents/venice-models.js"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export { VENICE_DEFAULT_MODEL_REF }; + +export function applyVeniceProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[VENICE_DEFAULT_MODEL_REF] = { + ...models[VENICE_DEFAULT_MODEL_REF], + alias: models[VENICE_DEFAULT_MODEL_REF]?.alias ?? "Kimi K2.5", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "venice", + api: "openai-completions", + baseUrl: VENICE_BASE_URL, + catalogModels: VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition), + }); +} + +export function applyVeniceConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyVeniceProviderConfig(cfg), VENICE_DEFAULT_MODEL_REF); +} diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index c9f3bcdf4de..b5f6830fd2e 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -4,10 +4,10 @@ import { getScopedCredentialValue, setScopedCredentialValue, } from "../../src/agents/tools/web-search-plugin-factory.js"; -import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js"; 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 { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "xai"; const XAI_MODERN_MODEL_PREFIXES = ["grok-4"] as const; diff --git a/extensions/xai/onboard.ts b/extensions/xai/onboard.ts new file mode 100644 index 00000000000..1404c6a4983 --- /dev/null +++ b/extensions/xai/onboard.ts @@ -0,0 +1,33 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModel, +} from "../../src/commands/onboard-auth.config-shared.js"; +import { + buildXaiModelDefinition, + XAI_BASE_URL, + XAI_DEFAULT_MODEL_ID, +} from "../../src/commands/onboard-auth.models.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`; + +export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[XAI_DEFAULT_MODEL_REF] = { + ...models[XAI_DEFAULT_MODEL_REF], + alias: models[XAI_DEFAULT_MODEL_REF]?.alias ?? "Grok", + }; + + return applyProviderConfigWithDefaultModel(cfg, { + agentModels: models, + providerId: "xai", + api: "openai-completions", + baseUrl: XAI_BASE_URL, + defaultModel: buildXaiModelDefinition(), + defaultModelId: XAI_DEFAULT_MODEL_ID, + }); +} + +export function applyXaiConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyXaiProviderConfig(cfg), XAI_DEFAULT_MODEL_REF); +} diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index 2b87dfee12a..05bcd699632 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -1,7 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -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 { applyXiaomiConfig, XIAOMI_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildXiaomiProvider } from "./provider-catalog.js"; const PROVIDER_ID = "xiaomi"; diff --git a/extensions/xiaomi/onboard.ts b/extensions/xiaomi/onboard.ts new file mode 100644 index 00000000000..3f3eef149c4 --- /dev/null +++ b/extensions/xiaomi/onboard.ts @@ -0,0 +1,30 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModels, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { buildXiaomiProvider, XIAOMI_DEFAULT_MODEL_ID } from "./provider-catalog.js"; + +export const XIAOMI_DEFAULT_MODEL_REF = `xiaomi/${XIAOMI_DEFAULT_MODEL_ID}`; + +export function applyXiaomiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[XIAOMI_DEFAULT_MODEL_REF] = { + ...models[XIAOMI_DEFAULT_MODEL_REF], + alias: models[XIAOMI_DEFAULT_MODEL_REF]?.alias ?? "Xiaomi", + }; + const defaultProvider = buildXiaomiProvider(); + const resolvedApi = defaultProvider.api ?? "openai-completions"; + return applyProviderConfigWithDefaultModels(cfg, { + agentModels: models, + providerId: "xiaomi", + api: resolvedApi, + baseUrl: defaultProvider.baseUrl, + defaultModels: defaultProvider.models ?? [], + defaultModelId: XIAOMI_DEFAULT_MODEL_ID, + }); +} + +export function applyXiaomiConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyXiaomiProviderConfig(cfg), XIAOMI_DEFAULT_MODEL_REF); +} diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 9064f5bfc58..3ac720034f7 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -1,48 +1,7 @@ import { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js"; -import { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js"; -import { - QIANFAN_DEFAULT_MODEL_ID, - buildQianfanProvider, -} from "../../extensions/qianfan/provider-catalog.js"; -import { - XIAOMI_DEFAULT_MODEL_ID, - buildXiaomiProvider, -} from "../../extensions/xiaomi/provider-catalog.js"; -import { - buildHuggingfaceModelDefinition, - HUGGINGFACE_BASE_URL, - HUGGINGFACE_MODEL_CATALOG, -} from "../agents/huggingface-models.js"; -import { - buildSyntheticModelDefinition, - SYNTHETIC_BASE_URL, - SYNTHETIC_DEFAULT_MODEL_REF, - SYNTHETIC_MODEL_CATALOG, -} from "../agents/synthetic-models.js"; -import { - buildTogetherModelDefinition, - TOGETHER_BASE_URL, - TOGETHER_MODEL_CATALOG, -} from "../agents/together-models.js"; -import { - buildVeniceModelDefinition, - VENICE_BASE_URL, - VENICE_DEFAULT_MODEL_REF, - VENICE_MODEL_CATALOG, -} from "../agents/venice-models.js"; import type { OpenClawConfig } from "../config/config.js"; -import type { ModelApi } from "../config/types.models.js"; import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js"; -import { - HUGGINGFACE_DEFAULT_MODEL_REF, - KILOCODE_DEFAULT_MODEL_REF, - MISTRAL_DEFAULT_MODEL_REF, - OPENROUTER_DEFAULT_MODEL_REF, - TOGETHER_DEFAULT_MODEL_REF, - XIAOMI_DEFAULT_MODEL_REF, - ZAI_DEFAULT_MODEL_REF, - XAI_DEFAULT_MODEL_REF, -} from "./onboard-auth.credentials.js"; +import { KILOCODE_DEFAULT_MODEL_REF, ZAI_DEFAULT_MODEL_REF } from "./onboard-auth.credentials.js"; export { applyCloudflareAiGatewayConfig, applyCloudflareAiGatewayProviderConfig, @@ -58,34 +17,66 @@ export { import { applyAgentDefaultModelPrimary, applyOnboardAuthAgentModelsAndProviders, - applyProviderConfigWithDefaultModel, - applyProviderConfigWithDefaultModels, applyProviderConfigWithModelCatalog, } from "./onboard-auth.config-shared.js"; import { - buildMistralModelDefinition, buildZaiModelDefinition, - buildMoonshotModelDefinition, - buildXaiModelDefinition, buildModelStudioModelDefinition, - MISTRAL_BASE_URL, - MISTRAL_DEFAULT_MODEL_ID, - QIANFAN_BASE_URL, - QIANFAN_DEFAULT_MODEL_REF, - KIMI_CODING_MODEL_ID, - KIMI_CODING_MODEL_REF, - MOONSHOT_BASE_URL, - MOONSHOT_CN_BASE_URL, - MOONSHOT_DEFAULT_MODEL_ID, - MOONSHOT_DEFAULT_MODEL_REF, ZAI_DEFAULT_MODEL_ID, resolveZaiBaseUrl, - XAI_BASE_URL, - XAI_DEFAULT_MODEL_ID, MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_GLOBAL_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, } from "./onboard-auth.models.js"; +export { + applyHuggingfaceConfig, + applyHuggingfaceProviderConfig, + HUGGINGFACE_DEFAULT_MODEL_REF, +} from "../../extensions/huggingface/onboard.js"; +export { + applyKimiCodeConfig, + applyKimiCodeProviderConfig, +} from "../../extensions/kimi-coding/onboard.js"; +export { + applyMistralConfig, + applyMistralProviderConfig, + MISTRAL_DEFAULT_MODEL_REF, +} from "../../extensions/mistral/onboard.js"; +export { + applyMoonshotConfig, + applyMoonshotConfigCn, + applyMoonshotProviderConfig, + applyMoonshotProviderConfigCn, +} from "../../extensions/moonshot/onboard.js"; +export { + applyOpenrouterConfig, + applyOpenrouterProviderConfig, +} from "../../extensions/openrouter/onboard.js"; +export { + applyQianfanConfig, + applyQianfanProviderConfig, +} from "../../extensions/qianfan/onboard.js"; +export { + applySyntheticConfig, + applySyntheticProviderConfig, + SYNTHETIC_DEFAULT_MODEL_REF, +} from "../../extensions/synthetic/onboard.js"; +export { + applyTogetherConfig, + applyTogetherProviderConfig, + TOGETHER_DEFAULT_MODEL_REF, +} from "../../extensions/together/onboard.js"; +export { + applyVeniceConfig, + applyVeniceProviderConfig, + VENICE_DEFAULT_MODEL_REF, +} from "../../extensions/venice/onboard.js"; +export { applyXiaomiConfig, applyXiaomiProviderConfig } from "../../extensions/xiaomi/onboard.js"; +export { + applyXaiConfig, + applyXaiProviderConfig, + XAI_DEFAULT_MODEL_REF, +} from "../../extensions/xai/onboard.js"; export { applyAuthProfileConfig } from "./auth-profile-config.js"; function mergeProviderModels( @@ -169,291 +160,6 @@ export function applyZaiConfig( return applyAgentDefaultModelPrimary(next, modelRef); } -export function applyOpenrouterProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[OPENROUTER_DEFAULT_MODEL_REF] = { - ...models[OPENROUTER_DEFAULT_MODEL_REF], - alias: models[OPENROUTER_DEFAULT_MODEL_REF]?.alias ?? "OpenRouter", - }; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }; -} - -export function applyOpenrouterConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyOpenrouterProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, OPENROUTER_DEFAULT_MODEL_REF); -} - -export function applyMoonshotProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - return applyMoonshotProviderConfigWithBaseUrl(cfg, MOONSHOT_BASE_URL); -} - -export function applyMoonshotProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig { - return applyMoonshotProviderConfigWithBaseUrl(cfg, MOONSHOT_CN_BASE_URL); -} - -function applyMoonshotProviderConfigWithBaseUrl( - cfg: OpenClawConfig, - baseUrl: string, -): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[MOONSHOT_DEFAULT_MODEL_REF] = { - ...models[MOONSHOT_DEFAULT_MODEL_REF], - alias: models[MOONSHOT_DEFAULT_MODEL_REF]?.alias ?? "Kimi", - }; - - const defaultModel = buildMoonshotModelDefinition(); - - return applyProviderConfigWithDefaultModel(cfg, { - agentModels: models, - providerId: "moonshot", - api: "openai-completions", - baseUrl, - defaultModel, - defaultModelId: MOONSHOT_DEFAULT_MODEL_ID, - }); -} - -export function applyMoonshotConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyMoonshotProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, MOONSHOT_DEFAULT_MODEL_REF); -} - -export function applyMoonshotConfigCn(cfg: OpenClawConfig): OpenClawConfig { - const next = applyMoonshotProviderConfigCn(cfg); - return applyAgentDefaultModelPrimary(next, MOONSHOT_DEFAULT_MODEL_REF); -} - -export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[KIMI_CODING_MODEL_REF] = { - ...models[KIMI_CODING_MODEL_REF], - alias: models[KIMI_CODING_MODEL_REF]?.alias ?? "Kimi for Coding", - }; - - const defaultModel = buildKimiCodingProvider().models[0]; - - return applyProviderConfigWithDefaultModel(cfg, { - agentModels: models, - providerId: "kimi-coding", - api: "anthropic-messages", - baseUrl: "https://api.kimi.com/coding/", - defaultModel, - defaultModelId: KIMI_CODING_MODEL_ID, - }); -} - -export function applyKimiCodeConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyKimiCodeProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, KIMI_CODING_MODEL_REF); -} - -export function applySyntheticProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[SYNTHETIC_DEFAULT_MODEL_REF] = { - ...models[SYNTHETIC_DEFAULT_MODEL_REF], - alias: models[SYNTHETIC_DEFAULT_MODEL_REF]?.alias ?? "MiniMax M2.5", - }; - - const providers = { ...cfg.models?.providers }; - const existingProvider = providers.synthetic; - const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; - const syntheticModels = SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition); - const mergedModels = [ - ...existingModels, - ...syntheticModels.filter( - (model) => !existingModels.some((existing) => existing.id === model.id), - ), - ]; - const { apiKey: _existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< - string, - unknown - > as { apiKey?: string }; - const normalizedApiKey = getNormalizedProviderApiKey(existingProvider); - providers.synthetic = { - ...existingProviderRest, - baseUrl: SYNTHETIC_BASE_URL, - api: "anthropic-messages", - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - models: mergedModels.length > 0 ? mergedModels : syntheticModels, - }; - - return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); -} - -export function applySyntheticConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applySyntheticProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, SYNTHETIC_DEFAULT_MODEL_REF); -} - -export function applyXiaomiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[XIAOMI_DEFAULT_MODEL_REF] = { - ...models[XIAOMI_DEFAULT_MODEL_REF], - alias: models[XIAOMI_DEFAULT_MODEL_REF]?.alias ?? "Xiaomi", - }; - const defaultProvider = buildXiaomiProvider(); - const resolvedApi = defaultProvider.api ?? "openai-completions"; - return applyProviderConfigWithDefaultModels(cfg, { - agentModels: models, - providerId: "xiaomi", - api: resolvedApi, - baseUrl: defaultProvider.baseUrl, - defaultModels: defaultProvider.models ?? [], - defaultModelId: XIAOMI_DEFAULT_MODEL_ID, - }); -} - -export function applyXiaomiConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyXiaomiProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, XIAOMI_DEFAULT_MODEL_REF); -} - -/** - * Apply Venice provider configuration without changing the default model. - * Registers Venice models and sets up the provider, but preserves existing model selection. - */ -export function applyVeniceProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[VENICE_DEFAULT_MODEL_REF] = { - ...models[VENICE_DEFAULT_MODEL_REF], - alias: models[VENICE_DEFAULT_MODEL_REF]?.alias ?? "Kimi K2.5", - }; - - const veniceModels = VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition); - return applyProviderConfigWithModelCatalog(cfg, { - agentModels: models, - providerId: "venice", - api: "openai-completions", - baseUrl: VENICE_BASE_URL, - catalogModels: veniceModels, - }); -} - -/** - * Apply Venice provider configuration AND set Venice as the default model. - * Use this when Venice is the primary provider choice during setup. - */ -export function applyVeniceConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyVeniceProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, VENICE_DEFAULT_MODEL_REF); -} - -/** - * Apply Together provider configuration without changing the default model. - * Registers Together models and sets up the provider, but preserves existing model selection. - */ -export function applyTogetherProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[TOGETHER_DEFAULT_MODEL_REF] = { - ...models[TOGETHER_DEFAULT_MODEL_REF], - alias: models[TOGETHER_DEFAULT_MODEL_REF]?.alias ?? "Together AI", - }; - - const togetherModels = TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition); - return applyProviderConfigWithModelCatalog(cfg, { - agentModels: models, - providerId: "together", - api: "openai-completions", - baseUrl: TOGETHER_BASE_URL, - catalogModels: togetherModels, - }); -} - -/** - * Apply Together provider configuration AND set Together as the default model. - * Use this when Together is the primary provider choice during setup. - */ -export function applyTogetherConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyTogetherProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, TOGETHER_DEFAULT_MODEL_REF); -} - -/** - * Apply Hugging Face (Inference Providers) provider configuration without changing the default model. - */ -export function applyHuggingfaceProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[HUGGINGFACE_DEFAULT_MODEL_REF] = { - ...models[HUGGINGFACE_DEFAULT_MODEL_REF], - alias: models[HUGGINGFACE_DEFAULT_MODEL_REF]?.alias ?? "Hugging Face", - }; - - const hfModels = HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition); - return applyProviderConfigWithModelCatalog(cfg, { - agentModels: models, - providerId: "huggingface", - api: "openai-completions", - baseUrl: HUGGINGFACE_BASE_URL, - catalogModels: hfModels, - }); -} - -/** - * Apply Hugging Face provider configuration AND set Hugging Face as the default model. - */ -export function applyHuggingfaceConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyHuggingfaceProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, HUGGINGFACE_DEFAULT_MODEL_REF); -} - -export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[XAI_DEFAULT_MODEL_REF] = { - ...models[XAI_DEFAULT_MODEL_REF], - alias: models[XAI_DEFAULT_MODEL_REF]?.alias ?? "Grok", - }; - - const defaultModel = buildXaiModelDefinition(); - - return applyProviderConfigWithDefaultModel(cfg, { - agentModels: models, - providerId: "xai", - api: "openai-completions", - baseUrl: XAI_BASE_URL, - defaultModel, - defaultModelId: XAI_DEFAULT_MODEL_ID, - }); -} - -export function applyXaiConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyXaiProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, XAI_DEFAULT_MODEL_REF); -} - -export function applyMistralProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[MISTRAL_DEFAULT_MODEL_REF] = { - ...models[MISTRAL_DEFAULT_MODEL_REF], - alias: models[MISTRAL_DEFAULT_MODEL_REF]?.alias ?? "Mistral", - }; - - const defaultModel = buildMistralModelDefinition(); - - return applyProviderConfigWithDefaultModel(cfg, { - agentModels: models, - providerId: "mistral", - api: "openai-completions", - baseUrl: MISTRAL_BASE_URL, - defaultModel, - defaultModelId: MISTRAL_DEFAULT_MODEL_ID, - }); -} - -export function applyMistralConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyMistralProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, MISTRAL_DEFAULT_MODEL_REF); -} - export { KILOCODE_BASE_URL }; /** @@ -487,42 +193,6 @@ export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { return applyAgentDefaultModelPrimary(next, KILOCODE_DEFAULT_MODEL_REF); } -export function applyQianfanProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[QIANFAN_DEFAULT_MODEL_REF] = { - ...models[QIANFAN_DEFAULT_MODEL_REF], - alias: models[QIANFAN_DEFAULT_MODEL_REF]?.alias ?? "QIANFAN", - }; - const defaultProvider = buildQianfanProvider(); - const existingProvider = cfg.models?.providers?.qianfan as - | { - baseUrl?: unknown; - api?: unknown; - } - | undefined; - const existingBaseUrl = - typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : ""; - const resolvedBaseUrl = existingBaseUrl || QIANFAN_BASE_URL; - const resolvedApi = - typeof existingProvider?.api === "string" - ? (existingProvider.api as ModelApi) - : "openai-completions"; - - return applyProviderConfigWithDefaultModels(cfg, { - agentModels: models, - providerId: "qianfan", - api: resolvedApi, - baseUrl: resolvedBaseUrl, - defaultModels: defaultProvider.models ?? [], - defaultModelId: QIANFAN_DEFAULT_MODEL_ID, - }); -} - -export function applyQianfanConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyQianfanProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, QIANFAN_DEFAULT_MODEL_REF); -} - // Alibaba Cloud Model Studio Coding Plan function applyModelStudioProviderConfigWithBaseUrl( diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index f51e61a8cee..fdc2aa0b27f 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -1,8 +1,5 @@ -export { - SYNTHETIC_DEFAULT_MODEL_ID, - SYNTHETIC_DEFAULT_MODEL_REF, -} from "../agents/synthetic-models.js"; -export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js"; +export { SYNTHETIC_DEFAULT_MODEL_ID } from "../agents/synthetic-models.js"; +export { VENICE_DEFAULT_MODEL_ID } from "../agents/venice-models.js"; export { applyAuthProfileConfig, applyCloudflareAiGatewayConfig, @@ -91,15 +88,17 @@ export { setXaiApiKey, setModelStudioApiKey, writeOAuthCredentials, - HUGGINGFACE_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, XIAOMI_DEFAULT_MODEL_REF, ZAI_DEFAULT_MODEL_REF, - TOGETHER_DEFAULT_MODEL_REF, - MISTRAL_DEFAULT_MODEL_REF, - XAI_DEFAULT_MODEL_REF, MODELSTUDIO_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; +export { HUGGINGFACE_DEFAULT_MODEL_REF } from "../../extensions/huggingface/onboard.js"; +export { MISTRAL_DEFAULT_MODEL_REF } from "../../extensions/mistral/onboard.js"; +export { SYNTHETIC_DEFAULT_MODEL_REF } from "../../extensions/synthetic/onboard.js"; +export { TOGETHER_DEFAULT_MODEL_REF } from "../../extensions/together/onboard.js"; +export { VENICE_DEFAULT_MODEL_REF } from "../../extensions/venice/onboard.js"; +export { XAI_DEFAULT_MODEL_REF } from "../../extensions/xai/onboard.js"; export { buildKilocodeModelDefinition, buildMinimaxApiModelDefinition, From f6d3aaa442a0f9bb440626cec1365942c75e485d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 19:56:19 -0700 Subject: [PATCH 033/128] refactor: move remaining provider onboarding into extensions --- extensions/kilocode/index.ts | 5 +- extensions/kilocode/onboard.ts | 35 ++++ extensions/modelstudio/index.ts | 4 +- extensions/modelstudio/onboard.ts | 61 ++++++ extensions/zai/index.ts | 8 +- extensions/zai/onboard.ts | 57 ++++++ src/commands/onboard-auth.config-core.ts | 236 +++-------------------- src/commands/onboard-auth.ts | 6 +- 8 files changed, 183 insertions(+), 229 deletions(-) create mode 100644 extensions/kilocode/onboard.ts create mode 100644 extensions/modelstudio/onboard.ts create mode 100644 extensions/zai/onboard.ts diff --git a/extensions/kilocode/index.ts b/extensions/kilocode/index.ts index 1eba870856c..3d58bebbf84 100644 --- a/extensions/kilocode/index.ts +++ b/extensions/kilocode/index.ts @@ -3,11 +3,8 @@ import { createKilocodeWrapper, isProxyReasoningUnsupported, } from "../../src/agents/pi-embedded-runner/proxy-stream-wrappers.js"; -import { - applyKilocodeConfig, - KILOCODE_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyKilocodeConfig, KILOCODE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildKilocodeProviderWithDiscovery } from "./provider-catalog.js"; const PROVIDER_ID = "kilocode"; diff --git a/extensions/kilocode/onboard.ts b/extensions/kilocode/onboard.ts new file mode 100644 index 00000000000..260233c3d34 --- /dev/null +++ b/extensions/kilocode/onboard.ts @@ -0,0 +1,35 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { + KILOCODE_BASE_URL, + KILOCODE_DEFAULT_MODEL_REF, +} from "../../src/providers/kilocode-shared.js"; +import { buildKilocodeProvider } from "./provider-catalog.js"; + +export { KILOCODE_BASE_URL, KILOCODE_DEFAULT_MODEL_REF }; + +export function applyKilocodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[KILOCODE_DEFAULT_MODEL_REF] = { + ...models[KILOCODE_DEFAULT_MODEL_REF], + alias: models[KILOCODE_DEFAULT_MODEL_REF]?.alias ?? "Kilo Gateway", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "kilocode", + api: "openai-completions", + baseUrl: KILOCODE_BASE_URL, + catalogModels: buildKilocodeProvider().models ?? [], + }); +} + +export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyKilocodeProviderConfig(cfg), + KILOCODE_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/modelstudio/index.ts b/extensions/modelstudio/index.ts index 08e8730dfbc..fd1cfd828af 100644 --- a/extensions/modelstudio/index.ts +++ b/extensions/modelstudio/index.ts @@ -1,10 +1,10 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { applyModelStudioConfig, applyModelStudioConfigCn, MODELSTUDIO_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +} from "./onboard.js"; import { buildModelStudioProvider } from "./provider-catalog.js"; const PROVIDER_ID = "modelstudio"; diff --git a/extensions/modelstudio/onboard.ts b/extensions/modelstudio/onboard.ts new file mode 100644 index 00000000000..e8d7d5bbacb --- /dev/null +++ b/extensions/modelstudio/onboard.ts @@ -0,0 +1,61 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import { + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, +} from "../../src/commands/onboard-auth.models.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { buildModelStudioProvider } from "./provider-catalog.js"; + +export { MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL }; + +function applyModelStudioProviderConfigWithBaseUrl( + cfg: OpenClawConfig, + baseUrl: string, +): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + const provider = buildModelStudioProvider(); + for (const model of provider.models ?? []) { + const modelRef = `modelstudio/${model.id}`; + if (!models[modelRef]) { + models[modelRef] = {}; + } + } + models[MODELSTUDIO_DEFAULT_MODEL_REF] = { + ...models[MODELSTUDIO_DEFAULT_MODEL_REF], + alias: models[MODELSTUDIO_DEFAULT_MODEL_REF]?.alias ?? "Qwen", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "modelstudio", + api: provider.api ?? "openai-completions", + baseUrl, + catalogModels: provider.models ?? [], + }); +} + +export function applyModelStudioProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_GLOBAL_BASE_URL); +} + +export function applyModelStudioProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig { + return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_CN_BASE_URL); +} + +export function applyModelStudioConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyModelStudioProviderConfig(cfg), + MODELSTUDIO_DEFAULT_MODEL_REF, + ); +} + +export function applyModelStudioConfigCn(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyModelStudioProviderConfigCn(cfg), + MODELSTUDIO_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index 16f1c311ea3..aee000ec412 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -20,17 +20,13 @@ import { } from "../../src/commands/auth-choice.api-key.js"; import { ensureApiKeyFromOptionEnvOrPrompt } from "../../src/commands/auth-choice.apply-helpers.js"; import { buildApiKeyCredential } from "../../src/commands/onboard-auth.credentials.js"; -import { - applyAuthProfileConfig, - applyZaiConfig, - applyZaiProviderConfig, - ZAI_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; +import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; import type { SecretInput } from "../../src/config/types.secrets.js"; import { resolveRequiredHomeDir } from "../../src/infra/home-dir.js"; import { fetchZaiUsage } from "../../src/infra/provider-usage.fetch.js"; import { normalizeOptionalSecretInput } from "../../src/utils/normalize-secret-input.js"; import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js"; +import { applyZaiConfig, applyZaiProviderConfig, ZAI_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "zai"; const GLM5_MODEL_ID = "glm-5"; diff --git a/extensions/zai/onboard.ts b/extensions/zai/onboard.ts new file mode 100644 index 00000000000..4e03994b2a7 --- /dev/null +++ b/extensions/zai/onboard.ts @@ -0,0 +1,57 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import { + buildZaiModelDefinition, + resolveZaiBaseUrl, + ZAI_DEFAULT_MODEL_ID, +} from "../../src/commands/onboard-auth.models.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export const ZAI_DEFAULT_MODEL_REF = `zai/${ZAI_DEFAULT_MODEL_ID}`; + +const ZAI_DEFAULT_MODELS = [ + buildZaiModelDefinition({ id: "glm-5" }), + buildZaiModelDefinition({ id: "glm-5-turbo" }), + buildZaiModelDefinition({ id: "glm-4.7" }), + buildZaiModelDefinition({ id: "glm-4.7-flash" }), + buildZaiModelDefinition({ id: "glm-4.7-flashx" }), +]; + +export function applyZaiProviderConfig( + cfg: OpenClawConfig, + params?: { endpoint?: string; modelId?: string }, +): OpenClawConfig { + const modelId = params?.modelId?.trim() || ZAI_DEFAULT_MODEL_ID; + const modelRef = `zai/${modelId}`; + const existingProvider = cfg.models?.providers?.zai; + const models = { ...cfg.agents?.defaults?.models }; + models[modelRef] = { + ...models[modelRef], + alias: models[modelRef]?.alias ?? "GLM", + }; + + const existingBaseUrl = + typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : ""; + const baseUrl = params?.endpoint + ? resolveZaiBaseUrl(params.endpoint) + : existingBaseUrl || resolveZaiBaseUrl(); + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "zai", + api: "openai-completions", + baseUrl, + catalogModels: ZAI_DEFAULT_MODELS, + }); +} + +export function applyZaiConfig( + cfg: OpenClawConfig, + params?: { endpoint?: string; modelId?: string }, +): OpenClawConfig { + const modelId = params?.modelId?.trim() || ZAI_DEFAULT_MODEL_ID; + const modelRef = modelId === ZAI_DEFAULT_MODEL_ID ? ZAI_DEFAULT_MODEL_REF : `zai/${modelId}`; + return applyAgentDefaultModelPrimary(applyZaiProviderConfig(cfg, params), modelRef); +} diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 3ac720034f7..7a78df71144 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -1,7 +1,3 @@ -import { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js"; -import { KILOCODE_DEFAULT_MODEL_REF, ZAI_DEFAULT_MODEL_REF } from "./onboard-auth.credentials.js"; export { applyCloudflareAiGatewayConfig, applyCloudflareAiGatewayProviderConfig, @@ -14,20 +10,7 @@ export { LITELLM_BASE_URL, LITELLM_DEFAULT_MODEL_ID, } from "./onboard-auth.config-litellm.js"; -import { - applyAgentDefaultModelPrimary, - applyOnboardAuthAgentModelsAndProviders, - applyProviderConfigWithModelCatalog, -} from "./onboard-auth.config-shared.js"; -import { - buildZaiModelDefinition, - buildModelStudioModelDefinition, - ZAI_DEFAULT_MODEL_ID, - resolveZaiBaseUrl, - MODELSTUDIO_CN_BASE_URL, - MODELSTUDIO_GLOBAL_BASE_URL, - MODELSTUDIO_DEFAULT_MODEL_REF, -} from "./onboard-auth.models.js"; +export { applyAuthProfileConfig } from "./auth-profile-config.js"; export { applyHuggingfaceConfig, applyHuggingfaceProviderConfig, @@ -37,11 +20,26 @@ export { applyKimiCodeConfig, applyKimiCodeProviderConfig, } from "../../extensions/kimi-coding/onboard.js"; +export { + applyKilocodeConfig, + applyKilocodeProviderConfig, + KILOCODE_BASE_URL, + KILOCODE_DEFAULT_MODEL_REF, +} from "../../extensions/kilocode/onboard.js"; export { applyMistralConfig, applyMistralProviderConfig, MISTRAL_DEFAULT_MODEL_REF, } from "../../extensions/mistral/onboard.js"; +export { + applyModelStudioConfig, + applyModelStudioConfigCn, + applyModelStudioProviderConfig, + applyModelStudioProviderConfigCn, + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, +} from "../../extensions/modelstudio/onboard.js"; export { applyMoonshotConfig, applyMoonshotConfigCn, @@ -71,204 +69,14 @@ export { applyVeniceProviderConfig, VENICE_DEFAULT_MODEL_REF, } from "../../extensions/venice/onboard.js"; -export { applyXiaomiConfig, applyXiaomiProviderConfig } from "../../extensions/xiaomi/onboard.js"; export { applyXaiConfig, applyXaiProviderConfig, XAI_DEFAULT_MODEL_REF, } from "../../extensions/xai/onboard.js"; -export { applyAuthProfileConfig } from "./auth-profile-config.js"; - -function mergeProviderModels( - existingProvider: Record | undefined, - defaultModels: T[], -): T[] { - const existingModels = Array.isArray(existingProvider?.models) - ? (existingProvider.models as T[]) - : []; - const mergedModels = [...existingModels]; - const seen = new Set(existingModels.map((model) => model.id)); - for (const model of defaultModels) { - if (!seen.has(model.id)) { - mergedModels.push(model); - seen.add(model.id); - } - } - return mergedModels; -} - -function getNormalizedProviderApiKey(existingProvider: Record | undefined) { - const { apiKey } = (existingProvider ?? {}) as { apiKey?: string }; - return typeof apiKey === "string" ? apiKey.trim() || undefined : undefined; -} - -export function applyZaiProviderConfig( - cfg: OpenClawConfig, - params?: { endpoint?: string; modelId?: string }, -): OpenClawConfig { - const modelId = params?.modelId?.trim() || ZAI_DEFAULT_MODEL_ID; - const modelRef = `zai/${modelId}`; - - const models = { ...cfg.agents?.defaults?.models }; - models[modelRef] = { - ...models[modelRef], - alias: models[modelRef]?.alias ?? "GLM", - }; - - const providers = { ...cfg.models?.providers }; - const existingProvider = providers.zai; - - const defaultModels = [ - buildZaiModelDefinition({ id: "glm-5" }), - buildZaiModelDefinition({ id: "glm-5-turbo" }), - buildZaiModelDefinition({ id: "glm-4.7" }), - buildZaiModelDefinition({ id: "glm-4.7-flash" }), - buildZaiModelDefinition({ id: "glm-4.7-flashx" }), - ]; - - const mergedModels = mergeProviderModels(existingProvider, defaultModels); - - const { apiKey: _existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< - string, - unknown - > as { apiKey?: string }; - const normalizedApiKey = getNormalizedProviderApiKey(existingProvider); - - const baseUrl = params?.endpoint - ? resolveZaiBaseUrl(params.endpoint) - : (typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl : "") || - resolveZaiBaseUrl(); - - providers.zai = { - ...existingProviderRest, - baseUrl, - api: "openai-completions", - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - models: mergedModels.length > 0 ? mergedModels : defaultModels, - }; - - return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); -} - -export function applyZaiConfig( - cfg: OpenClawConfig, - params?: { endpoint?: string; modelId?: string }, -): OpenClawConfig { - const modelId = params?.modelId?.trim() || ZAI_DEFAULT_MODEL_ID; - const modelRef = modelId === ZAI_DEFAULT_MODEL_ID ? ZAI_DEFAULT_MODEL_REF : `zai/${modelId}`; - const next = applyZaiProviderConfig(cfg, params); - return applyAgentDefaultModelPrimary(next, modelRef); -} - -export { KILOCODE_BASE_URL }; - -/** - * Apply Kilo Gateway provider configuration without changing the default model. - * Registers Kilo Gateway and sets up the provider, but preserves existing model selection. - */ -export function applyKilocodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[KILOCODE_DEFAULT_MODEL_REF] = { - ...models[KILOCODE_DEFAULT_MODEL_REF], - alias: models[KILOCODE_DEFAULT_MODEL_REF]?.alias ?? "Kilo Gateway", - }; - - const kilocodeModels = buildKilocodeProvider().models ?? []; - - return applyProviderConfigWithModelCatalog(cfg, { - agentModels: models, - providerId: "kilocode", - api: "openai-completions", - baseUrl: KILOCODE_BASE_URL, - catalogModels: kilocodeModels, - }); -} - -/** - * Apply Kilo Gateway provider configuration AND set Kilo Gateway as the default model. - * Use this when Kilo Gateway is the primary provider choice during setup. - */ -export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyKilocodeProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, KILOCODE_DEFAULT_MODEL_REF); -} - -// Alibaba Cloud Model Studio Coding Plan - -function applyModelStudioProviderConfigWithBaseUrl( - cfg: OpenClawConfig, - baseUrl: string, -): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - - const modelStudioModelIds = [ - "qwen3.5-plus", - "qwen3-max-2026-01-23", - "qwen3-coder-next", - "qwen3-coder-plus", - "MiniMax-M2.5", - "glm-5", - "glm-4.7", - "kimi-k2.5", - ]; - for (const modelId of modelStudioModelIds) { - const modelRef = `modelstudio/${modelId}`; - if (!models[modelRef]) { - models[modelRef] = {}; - } - } - models[MODELSTUDIO_DEFAULT_MODEL_REF] = { - ...models[MODELSTUDIO_DEFAULT_MODEL_REF], - alias: models[MODELSTUDIO_DEFAULT_MODEL_REF]?.alias ?? "Qwen", - }; - - const providers = { ...cfg.models?.providers }; - const existingProvider = providers.modelstudio; - - const defaultModels = [ - buildModelStudioModelDefinition({ id: "qwen3.5-plus" }), - buildModelStudioModelDefinition({ id: "qwen3-max-2026-01-23" }), - buildModelStudioModelDefinition({ id: "qwen3-coder-next" }), - buildModelStudioModelDefinition({ id: "qwen3-coder-plus" }), - buildModelStudioModelDefinition({ id: "MiniMax-M2.5" }), - buildModelStudioModelDefinition({ id: "glm-5" }), - buildModelStudioModelDefinition({ id: "glm-4.7" }), - buildModelStudioModelDefinition({ id: "kimi-k2.5" }), - ]; - - const mergedModels = mergeProviderModels(existingProvider, defaultModels); - - const { apiKey: _existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< - string, - unknown - > as { apiKey?: string }; - const normalizedApiKey = getNormalizedProviderApiKey(existingProvider); - - providers.modelstudio = { - ...existingProviderRest, - baseUrl, - api: "openai-completions", - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - models: mergedModels.length > 0 ? mergedModels : defaultModels, - }; - - return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); -} - -export function applyModelStudioProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_GLOBAL_BASE_URL); -} - -export function applyModelStudioProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig { - return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_CN_BASE_URL); -} - -export function applyModelStudioConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyModelStudioProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, MODELSTUDIO_DEFAULT_MODEL_REF); -} - -export function applyModelStudioConfigCn(cfg: OpenClawConfig): OpenClawConfig { - const next = applyModelStudioProviderConfigCn(cfg); - return applyAgentDefaultModelPrimary(next, MODELSTUDIO_DEFAULT_MODEL_REF); -} +export { applyXiaomiConfig, applyXiaomiProviderConfig } from "../../extensions/xiaomi/onboard.js"; +export { + applyZaiConfig, + applyZaiProviderConfig, + ZAI_DEFAULT_MODEL_REF, +} from "../../extensions/zai/onboard.js"; diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index fdc2aa0b27f..d27a807c69d 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -59,7 +59,6 @@ export { } from "./onboard-auth.config-opencode-go.js"; export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, - KILOCODE_DEFAULT_MODEL_REF, LITELLM_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, setOpenaiApiKey, @@ -90,15 +89,16 @@ export { writeOAuthCredentials, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, XIAOMI_DEFAULT_MODEL_REF, - ZAI_DEFAULT_MODEL_REF, - MODELSTUDIO_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; export { HUGGINGFACE_DEFAULT_MODEL_REF } from "../../extensions/huggingface/onboard.js"; +export { KILOCODE_DEFAULT_MODEL_REF } from "../../extensions/kilocode/onboard.js"; export { MISTRAL_DEFAULT_MODEL_REF } from "../../extensions/mistral/onboard.js"; +export { MODELSTUDIO_DEFAULT_MODEL_REF } from "../../extensions/modelstudio/onboard.js"; export { SYNTHETIC_DEFAULT_MODEL_REF } from "../../extensions/synthetic/onboard.js"; export { TOGETHER_DEFAULT_MODEL_REF } from "../../extensions/together/onboard.js"; export { VENICE_DEFAULT_MODEL_REF } from "../../extensions/venice/onboard.js"; export { XAI_DEFAULT_MODEL_REF } from "../../extensions/xai/onboard.js"; +export { ZAI_DEFAULT_MODEL_REF } from "../../extensions/zai/onboard.js"; export { buildKilocodeModelDefinition, buildMinimaxApiModelDefinition, From 2182137bde62bb3dbbe03e5a86092125b338d1cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 19:58:13 -0700 Subject: [PATCH 034/128] refactor: move gateway onboarding into extensions --- extensions/cloudflare-ai-gateway/index.ts | 37 ++------ extensions/cloudflare-ai-gateway/onboard.ts | 93 +++++++++++++++++++ extensions/vercel-ai-gateway/index.ts | 5 +- extensions/vercel-ai-gateway/onboard.ts | 30 ++++++ src/commands/onboard-auth.config-gateways.ts | 97 ++------------------ src/commands/onboard-auth.ts | 4 +- 6 files changed, 141 insertions(+), 125 deletions(-) create mode 100644 extensions/cloudflare-ai-gateway/onboard.ts create mode 100644 extensions/vercel-ai-gateway/onboard.ts diff --git a/extensions/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts index ddc0bd7405a..782cb43786d 100644 --- a/extensions/cloudflare-ai-gateway/index.ts +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -12,14 +12,15 @@ import { } from "../../src/commands/auth-choice.api-key.js"; import { ensureApiKeyFromOptionEnvOrPrompt } from "../../src/commands/auth-choice.apply-helpers.js"; import { buildApiKeyCredential } from "../../src/commands/onboard-auth.credentials.js"; -import { - applyCloudflareAiGatewayConfig, - applyAuthProfileConfig, - CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; +import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; import type { SecretInput } from "../../src/config/types.secrets.js"; import { coerceSecretRef } from "../../src/config/types.secrets.js"; import { normalizeOptionalSecretInput } from "../../src/utils/normalize-secret-input.js"; +import { + applyCloudflareAiGatewayConfig, + buildCloudflareAiGatewayConfigPatch, + CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, +} from "./onboard.js"; const PROVIDER_ID = "cloudflare-ai-gateway"; const PROVIDER_ENV_VAR = "CLOUDFLARE_AI_GATEWAY_API_KEY"; @@ -53,30 +54,6 @@ function resolveMetadataFromCredential( }; } -function buildCloudflareConfigPatch(params: { accountId: string; gatewayId: string }) { - const baseUrl = resolveCloudflareAiGatewayBaseUrl(params); - return { - models: { - providers: { - [PROVIDER_ID]: { - baseUrl, - api: "anthropic-messages" as const, - models: [buildCloudflareAiGatewayModelDefinition()], - }, - }, - }, - agents: { - defaults: { - models: { - [CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]: { - alias: "Cloudflare AI Gateway", - }, - }, - }, - }, - }; -} - async function resolveCloudflareGatewayMetadataInteractive(ctx: { accountId?: string; gatewayId?: string; @@ -180,7 +157,7 @@ const cloudflareAiGatewayPlugin = { ), }, ], - configPatch: buildCloudflareConfigPatch(metadata), + configPatch: buildCloudflareAiGatewayConfigPatch(metadata), defaultModel: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, }; }, diff --git a/extensions/cloudflare-ai-gateway/onboard.ts b/extensions/cloudflare-ai-gateway/onboard.ts new file mode 100644 index 00000000000..267c2f806f1 --- /dev/null +++ b/extensions/cloudflare-ai-gateway/onboard.ts @@ -0,0 +1,93 @@ +import { + buildCloudflareAiGatewayModelDefinition, + CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, + resolveCloudflareAiGatewayBaseUrl, +} from "../../src/agents/cloudflare-ai-gateway.js"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModel, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF }; + +export function buildCloudflareAiGatewayConfigPatch(params: { + accountId: string; + gatewayId: string; +}) { + const baseUrl = resolveCloudflareAiGatewayBaseUrl(params); + return { + models: { + providers: { + "cloudflare-ai-gateway": { + baseUrl, + api: "anthropic-messages" as const, + models: [buildCloudflareAiGatewayModelDefinition()], + }, + }, + }, + agents: { + defaults: { + models: { + [CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]: { + alias: "Cloudflare AI Gateway", + }, + }, + }, + }, + }; +} + +export function applyCloudflareAiGatewayProviderConfig( + cfg: OpenClawConfig, + params?: { accountId?: string; gatewayId?: string }, +): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF] = { + ...models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF], + alias: models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Cloudflare AI Gateway", + }; + + const existingProvider = cfg.models?.providers?.["cloudflare-ai-gateway"] as + | { baseUrl?: unknown } + | undefined; + const baseUrl = + params?.accountId && params?.gatewayId + ? resolveCloudflareAiGatewayBaseUrl({ + accountId: params.accountId, + gatewayId: params.gatewayId, + }) + : typeof existingProvider?.baseUrl === "string" + ? existingProvider.baseUrl + : undefined; + if (!baseUrl) { + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; + } + + return applyProviderConfigWithDefaultModel(cfg, { + agentModels: models, + providerId: "cloudflare-ai-gateway", + api: "anthropic-messages", + baseUrl, + defaultModel: buildCloudflareAiGatewayModelDefinition(), + }); +} + +export function applyCloudflareAiGatewayConfig( + cfg: OpenClawConfig, + params?: { accountId?: string; gatewayId?: string }, +): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyCloudflareAiGatewayProviderConfig(cfg, params), + CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index 7946001981e..31f3ff3db70 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -1,9 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -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 { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildVercelAiGatewayProvider } from "./provider-catalog.js"; const PROVIDER_ID = "vercel-ai-gateway"; diff --git a/extensions/vercel-ai-gateway/onboard.ts b/extensions/vercel-ai-gateway/onboard.ts new file mode 100644 index 00000000000..d65d7224781 --- /dev/null +++ b/extensions/vercel-ai-gateway/onboard.ts @@ -0,0 +1,30 @@ +import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.6"; + +export function applyVercelAiGatewayProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF] = { + ...models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF], + alias: models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Vercel AI Gateway", + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; +} + +export function applyVercelAiGatewayConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyVercelAiGatewayProviderConfig(cfg), + VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + ); +} diff --git a/src/commands/onboard-auth.config-gateways.ts b/src/commands/onboard-auth.config-gateways.ts index a7a4d4246ce..4699481d79a 100644 --- a/src/commands/onboard-auth.config-gateways.ts +++ b/src/commands/onboard-auth.config-gateways.ts @@ -1,91 +1,10 @@ -import { - buildCloudflareAiGatewayModelDefinition, - resolveCloudflareAiGatewayBaseUrl, -} from "../agents/cloudflare-ai-gateway.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { - applyAgentDefaultModelPrimary, - applyProviderConfigWithDefaultModel, -} from "./onboard-auth.config-shared.js"; -import { +export { + applyCloudflareAiGatewayConfig, + applyCloudflareAiGatewayProviderConfig, CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, +} from "../../extensions/cloudflare-ai-gateway/onboard.js"; +export { + applyVercelAiGatewayConfig, + applyVercelAiGatewayProviderConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, -} from "./onboard-auth.credentials.js"; - -export function applyVercelAiGatewayProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF] = { - ...models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF], - alias: models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Vercel AI Gateway", - }; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }; -} - -export function applyCloudflareAiGatewayProviderConfig( - cfg: OpenClawConfig, - params?: { accountId?: string; gatewayId?: string }, -): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF] = { - ...models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF], - alias: models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Cloudflare AI Gateway", - }; - - const defaultModel = buildCloudflareAiGatewayModelDefinition(); - const existingProvider = cfg.models?.providers?.["cloudflare-ai-gateway"] as - | { baseUrl?: unknown } - | undefined; - const baseUrl = - params?.accountId && params?.gatewayId - ? resolveCloudflareAiGatewayBaseUrl({ - accountId: params.accountId, - gatewayId: params.gatewayId, - }) - : typeof existingProvider?.baseUrl === "string" - ? existingProvider.baseUrl - : undefined; - - if (!baseUrl) { - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }; - } - - return applyProviderConfigWithDefaultModel(cfg, { - agentModels: models, - providerId: "cloudflare-ai-gateway", - api: "anthropic-messages", - baseUrl, - defaultModel, - }); -} - -export function applyVercelAiGatewayConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyVercelAiGatewayProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF); -} - -export function applyCloudflareAiGatewayConfig( - cfg: OpenClawConfig, - params?: { accountId?: string; gatewayId?: string }, -): OpenClawConfig { - const next = applyCloudflareAiGatewayProviderConfig(cfg, params); - return applyAgentDefaultModelPrimary(next, CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF); -} +} from "../../extensions/vercel-ai-gateway/onboard.js"; diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index d27a807c69d..ac923e56710 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -58,7 +58,6 @@ export { applyOpencodeGoProviderConfig, } from "./onboard-auth.config-opencode-go.js"; export { - CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, LITELLM_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, setOpenaiApiKey, @@ -87,9 +86,9 @@ export { setXaiApiKey, setModelStudioApiKey, writeOAuthCredentials, - VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, XIAOMI_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; +export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../../extensions/cloudflare-ai-gateway/onboard.js"; export { HUGGINGFACE_DEFAULT_MODEL_REF } from "../../extensions/huggingface/onboard.js"; export { KILOCODE_DEFAULT_MODEL_REF } from "../../extensions/kilocode/onboard.js"; export { MISTRAL_DEFAULT_MODEL_REF } from "../../extensions/mistral/onboard.js"; @@ -97,6 +96,7 @@ export { MODELSTUDIO_DEFAULT_MODEL_REF } from "../../extensions/modelstudio/onbo export { SYNTHETIC_DEFAULT_MODEL_REF } from "../../extensions/synthetic/onboard.js"; export { TOGETHER_DEFAULT_MODEL_REF } from "../../extensions/together/onboard.js"; export { VENICE_DEFAULT_MODEL_REF } from "../../extensions/venice/onboard.js"; +export { VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "../../extensions/vercel-ai-gateway/onboard.js"; export { XAI_DEFAULT_MODEL_REF } from "../../extensions/xai/onboard.js"; export { ZAI_DEFAULT_MODEL_REF } from "../../extensions/zai/onboard.js"; export { From 763eff8b3268ae54c6fa5463c65e52f7e384c73d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:00:03 -0700 Subject: [PATCH 035/128] refactor: move plugin-specific config into extensions --- extensions/minimax/index.ts | 5 +- extensions/minimax/onboard.ts | 104 ++++++++++++++++ extensions/opencode-go/index.ts | 2 +- extensions/opencode-go/onboard.ts | 39 ++++++ extensions/opencode/index.ts | 2 +- extensions/opencode/onboard.ts | 31 +++++ src/commands/onboard-auth.config-minimax.ts | 112 +----------------- .../onboard-auth.config-opencode-go.ts | 41 +------ src/commands/onboard-auth.config-opencode.ts | 33 +----- 9 files changed, 193 insertions(+), 176 deletions(-) create mode 100644 extensions/minimax/onboard.ts create mode 100644 extensions/opencode-go/onboard.ts create mode 100644 extensions/opencode/onboard.ts diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index e87a60556fa..9330e9c4651 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -8,13 +8,10 @@ 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 { - applyMinimaxApiConfig, - applyMinimaxApiConfigCn, -} from "../../src/commands/onboard-auth.config-minimax.js"; 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 { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js"; import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js"; const API_PROVIDER_ID = "minimax"; diff --git a/extensions/minimax/onboard.ts b/extensions/minimax/onboard.ts new file mode 100644 index 00000000000..5c18a3c44ff --- /dev/null +++ b/extensions/minimax/onboard.ts @@ -0,0 +1,104 @@ +import { + applyAgentDefaultModelPrimary, + applyOnboardAuthAgentModelsAndProviders, +} from "../../src/commands/onboard-auth.config-shared.js"; +import { + buildMinimaxApiModelDefinition, + MINIMAX_API_BASE_URL, + MINIMAX_CN_API_BASE_URL, +} from "../../src/commands/onboard-auth.models.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; + +type MinimaxApiProviderConfigParams = { + providerId: string; + modelId: string; + baseUrl: string; +}; + +function applyMinimaxApiProviderConfigWithBaseUrl( + cfg: OpenClawConfig, + params: MinimaxApiProviderConfigParams, +): OpenClawConfig { + const providers = { ...cfg.models?.providers } as Record; + const existingProvider = providers[params.providerId]; + const existingModels = existingProvider?.models ?? []; + const apiModel = buildMinimaxApiModelDefinition(params.modelId); + const hasApiModel = existingModels.some((model) => model.id === params.modelId); + const mergedModels = hasApiModel ? existingModels : [...existingModels, apiModel]; + const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? { + baseUrl: params.baseUrl, + models: [], + }; + const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = resolvedApiKey?.trim() === "minimax" ? "" : resolvedApiKey; + providers[params.providerId] = { + ...existingProviderRest, + baseUrl: params.baseUrl, + api: "anthropic-messages", + authHeader: true, + ...(normalizedApiKey?.trim() ? { apiKey: normalizedApiKey } : {}), + models: mergedModels.length > 0 ? mergedModels : [apiModel], + }; + + const models = { ...cfg.agents?.defaults?.models }; + const modelRef = `${params.providerId}/${params.modelId}`; + models[modelRef] = { + ...models[modelRef], + alias: "Minimax", + }; + + return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); +} + +function applyMinimaxApiConfigWithBaseUrl( + cfg: OpenClawConfig, + params: MinimaxApiProviderConfigParams, +): OpenClawConfig { + const next = applyMinimaxApiProviderConfigWithBaseUrl(cfg, params); + return applyAgentDefaultModelPrimary(next, `${params.providerId}/${params.modelId}`); +} + +export function applyMinimaxApiProviderConfig( + cfg: OpenClawConfig, + modelId: string = "MiniMax-M2.5", +): OpenClawConfig { + return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { + providerId: "minimax", + modelId, + baseUrl: MINIMAX_API_BASE_URL, + }); +} + +export function applyMinimaxApiConfig( + cfg: OpenClawConfig, + modelId: string = "MiniMax-M2.5", +): OpenClawConfig { + return applyMinimaxApiConfigWithBaseUrl(cfg, { + providerId: "minimax", + modelId, + baseUrl: MINIMAX_API_BASE_URL, + }); +} + +export function applyMinimaxApiProviderConfigCn( + cfg: OpenClawConfig, + modelId: string = "MiniMax-M2.5", +): OpenClawConfig { + return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { + providerId: "minimax", + modelId, + baseUrl: MINIMAX_CN_API_BASE_URL, + }); +} + +export function applyMinimaxApiConfigCn( + cfg: OpenClawConfig, + modelId: string = "MiniMax-M2.5", +): OpenClawConfig { + return applyMinimaxApiConfigWithBaseUrl(cfg, { + providerId: "minimax", + modelId, + baseUrl: MINIMAX_CN_API_BASE_URL, + }); +} diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index c0a8cea9b91..ddfd9a5858c 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -1,7 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { applyOpencodeGoConfig } from "../../src/commands/onboard-auth.config-opencode-go.js"; import { OPENCODE_GO_DEFAULT_MODEL_REF } from "../../src/commands/opencode-go-model-default.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyOpencodeGoConfig } from "./onboard.js"; const PROVIDER_ID = "opencode-go"; diff --git a/extensions/opencode-go/onboard.ts b/extensions/opencode-go/onboard.ts new file mode 100644 index 00000000000..8ca47a0f9d0 --- /dev/null +++ b/extensions/opencode-go/onboard.ts @@ -0,0 +1,39 @@ +import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js"; +import { OPENCODE_GO_DEFAULT_MODEL_REF } from "../../src/commands/opencode-go-model-default.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export { OPENCODE_GO_DEFAULT_MODEL_REF }; + +const OPENCODE_GO_ALIAS_DEFAULTS: Record = { + "opencode-go/kimi-k2.5": "Kimi", + "opencode-go/glm-5": "GLM", + "opencode-go/minimax-m2.5": "MiniMax", +}; + +export function applyOpencodeGoProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + for (const [modelRef, alias] of Object.entries(OPENCODE_GO_ALIAS_DEFAULTS)) { + models[modelRef] = { + ...models[modelRef], + alias: models[modelRef]?.alias ?? alias, + }; + } + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; +} + +export function applyOpencodeGoConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyOpencodeGoProviderConfig(cfg), + OPENCODE_GO_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index d00ae301bc5..01ccea24656 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -1,7 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { applyOpencodeZenConfig } from "../../src/commands/onboard-auth.config-opencode.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "../../src/commands/opencode-zen-model-default.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyOpencodeZenConfig } from "./onboard.js"; const PROVIDER_ID = "opencode"; const MINIMAX_PREFIX = "minimax-m2.5"; diff --git a/extensions/opencode/onboard.ts b/extensions/opencode/onboard.ts new file mode 100644 index 00000000000..a308129b688 --- /dev/null +++ b/extensions/opencode/onboard.ts @@ -0,0 +1,31 @@ +import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../../src/agents/opencode-zen-models.js"; +import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export { OPENCODE_ZEN_DEFAULT_MODEL_REF }; + +export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[OPENCODE_ZEN_DEFAULT_MODEL_REF] = { + ...models[OPENCODE_ZEN_DEFAULT_MODEL_REF], + alias: models[OPENCODE_ZEN_DEFAULT_MODEL_REF]?.alias ?? "Opus", + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; +} + +export function applyOpencodeZenConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyOpencodeZenProviderConfig(cfg), + OPENCODE_ZEN_DEFAULT_MODEL_REF, + ); +} diff --git a/src/commands/onboard-auth.config-minimax.ts b/src/commands/onboard-auth.config-minimax.ts index 14ec734592b..8453154bb7f 100644 --- a/src/commands/onboard-auth.config-minimax.ts +++ b/src/commands/onboard-auth.config-minimax.ts @@ -1,106 +1,6 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { ModelProviderConfig } from "../config/types.models.js"; -import { - applyAgentDefaultModelPrimary, - applyOnboardAuthAgentModelsAndProviders, -} from "./onboard-auth.config-shared.js"; -import { - buildMinimaxApiModelDefinition, - MINIMAX_API_BASE_URL, - MINIMAX_CN_API_BASE_URL, -} from "./onboard-auth.models.js"; - -type MinimaxApiProviderConfigParams = { - providerId: string; - modelId: string; - baseUrl: string; -}; - -function applyMinimaxApiProviderConfigWithBaseUrl( - cfg: OpenClawConfig, - params: MinimaxApiProviderConfigParams, -): OpenClawConfig { - const providers = { ...cfg.models?.providers } as Record; - const existingProvider = providers[params.providerId]; - const existingModels = existingProvider?.models ?? []; - const apiModel = buildMinimaxApiModelDefinition(params.modelId); - const hasApiModel = existingModels.some((model) => model.id === params.modelId); - const mergedModels = hasApiModel ? existingModels : [...existingModels, apiModel]; - const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? { - baseUrl: params.baseUrl, - models: [], - }; - const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; - const normalizedApiKey = resolvedApiKey?.trim() === "minimax" ? "" : resolvedApiKey; - providers[params.providerId] = { - ...existingProviderRest, - baseUrl: params.baseUrl, - api: "anthropic-messages", - authHeader: true, - ...(normalizedApiKey?.trim() ? { apiKey: normalizedApiKey } : {}), - models: mergedModels.length > 0 ? mergedModels : [apiModel], - }; - - const models = { ...cfg.agents?.defaults?.models }; - const modelRef = `${params.providerId}/${params.modelId}`; - models[modelRef] = { - ...models[modelRef], - alias: "Minimax", - }; - - return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); -} - -function applyMinimaxApiConfigWithBaseUrl( - cfg: OpenClawConfig, - params: MinimaxApiProviderConfigParams, -): OpenClawConfig { - const next = applyMinimaxApiProviderConfigWithBaseUrl(cfg, params); - return applyAgentDefaultModelPrimary(next, `${params.providerId}/${params.modelId}`); -} - -// MiniMax Global API (platform.minimax.io/anthropic) -export function applyMinimaxApiProviderConfig( - cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", -): OpenClawConfig { - return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { - providerId: "minimax", - modelId, - baseUrl: MINIMAX_API_BASE_URL, - }); -} - -export function applyMinimaxApiConfig( - cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", -): OpenClawConfig { - return applyMinimaxApiConfigWithBaseUrl(cfg, { - providerId: "minimax", - modelId, - baseUrl: MINIMAX_API_BASE_URL, - }); -} - -// MiniMax CN API (api.minimaxi.com/anthropic) — same provider id, different baseUrl -export function applyMinimaxApiProviderConfigCn( - cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", -): OpenClawConfig { - return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { - providerId: "minimax", - modelId, - baseUrl: MINIMAX_CN_API_BASE_URL, - }); -} - -export function applyMinimaxApiConfigCn( - cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", -): OpenClawConfig { - return applyMinimaxApiConfigWithBaseUrl(cfg, { - providerId: "minimax", - modelId, - baseUrl: MINIMAX_CN_API_BASE_URL, - }); -} +export { + applyMinimaxApiConfig, + applyMinimaxApiConfigCn, + applyMinimaxApiProviderConfig, + applyMinimaxApiProviderConfigCn, +} from "../../extensions/minimax/onboard.js"; diff --git a/src/commands/onboard-auth.config-opencode-go.ts b/src/commands/onboard-auth.config-opencode-go.ts index 25be5ffa18f..eb31512e565 100644 --- a/src/commands/onboard-auth.config-opencode-go.ts +++ b/src/commands/onboard-auth.config-opencode-go.ts @@ -1,36 +1,5 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; -import { OPENCODE_GO_DEFAULT_MODEL_REF } from "./opencode-go-model-default.js"; - -const OPENCODE_GO_ALIAS_DEFAULTS: Record = { - "opencode-go/kimi-k2.5": "Kimi", - "opencode-go/glm-5": "GLM", - "opencode-go/minimax-m2.5": "MiniMax", -}; - -export function applyOpencodeGoProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - // Use the built-in opencode-go provider from pi-ai; only seed allowlist aliases. - const models = { ...cfg.agents?.defaults?.models }; - for (const [modelRef, alias] of Object.entries(OPENCODE_GO_ALIAS_DEFAULTS)) { - models[modelRef] = { - ...models[modelRef], - alias: models[modelRef]?.alias ?? alias, - }; - } - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }; -} - -export function applyOpencodeGoConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyOpencodeGoProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, OPENCODE_GO_DEFAULT_MODEL_REF); -} +export { + applyOpencodeGoConfig, + applyOpencodeGoProviderConfig, + OPENCODE_GO_DEFAULT_MODEL_REF, +} from "../../extensions/opencode-go/onboard.js"; diff --git a/src/commands/onboard-auth.config-opencode.ts b/src/commands/onboard-auth.config-opencode.ts index c9f1dd4725b..d9aa6f97436 100644 --- a/src/commands/onboard-auth.config-opencode.ts +++ b/src/commands/onboard-auth.config-opencode.ts @@ -1,28 +1,5 @@ -import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; - -export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - // Use the built-in opencode provider from pi-ai; only seed the allowlist alias. - const models = { ...cfg.agents?.defaults?.models }; - models[OPENCODE_ZEN_DEFAULT_MODEL_REF] = { - ...models[OPENCODE_ZEN_DEFAULT_MODEL_REF], - alias: models[OPENCODE_ZEN_DEFAULT_MODEL_REF]?.alias ?? "Opus", - }; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }; -} - -export function applyOpencodeZenConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyOpencodeZenProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, OPENCODE_ZEN_DEFAULT_MODEL_REF); -} +export { + applyOpencodeZenConfig, + applyOpencodeZenProviderConfig, + OPENCODE_ZEN_DEFAULT_MODEL_REF, +} from "../../extensions/opencode/onboard.js"; From 03f50365d7c68d61b785014b3d2da41d7b2b18e7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:01:49 -0700 Subject: [PATCH 036/128] refactor: rename kimi coding surface to kimi --- extensions/kimi-coding/index.ts | 24 ++++++++++----------- extensions/kimi-coding/onboard.ts | 2 +- extensions/kimi-coding/openclaw.plugin.json | 6 +++--- extensions/kimi-coding/provider-catalog.ts | 2 +- extensions/moonshot/index.ts | 8 +++---- extensions/moonshot/openclaw.plugin.json | 4 ++-- src/agents/provider-id.ts | 3 +++ 7 files changed, 26 insertions(+), 23 deletions(-) diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index ed193fe714b..a109cc1075a 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -8,40 +8,40 @@ const PROVIDER_ID = "kimi-coding"; const kimiCodingPlugin = { id: PROVIDER_ID, - name: "Kimi Coding Provider", - description: "Bundled Kimi Coding provider plugin", + name: "Kimi Provider", + description: "Bundled Kimi provider plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerProvider({ id: PROVIDER_ID, - label: "Kimi Coding", - aliases: ["kimi-code"], + label: "Kimi", + aliases: ["kimi", "kimi-code"], docsPath: "/providers/moonshot", envVars: ["KIMI_API_KEY", "KIMICODE_API_KEY"], auth: [ createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key", - label: "Kimi Code API key (subscription)", - hint: "Kimi K2.5 + Kimi Coding", + label: "Kimi API key (subscription)", + hint: "Kimi K2.5 + Kimi", optionKey: "kimiCodeApiKey", flagName: "--kimi-code-api-key", envVar: "KIMI_API_KEY", - promptMessage: "Enter Kimi Coding API key", + promptMessage: "Enter Kimi API key", defaultModel: KIMI_CODING_MODEL_REF, - expectedProviders: ["kimi-code", "kimi-coding"], + expectedProviders: ["kimi", "kimi-code", "kimi-coding"], applyConfig: (cfg) => applyKimiCodeConfig(cfg), noteMessage: [ - "Kimi Coding uses a dedicated endpoint and API key.", + "Kimi uses a dedicated coding endpoint and API key.", "Get your API key at: https://www.kimi.com/code/en", ].join("\n"), - noteTitle: "Kimi Coding", + noteTitle: "Kimi", wizard: { choiceId: "kimi-code-api-key", - choiceLabel: "Kimi Code API key (subscription)", + choiceLabel: "Kimi API key (subscription)", groupId: "moonshot", groupLabel: "Moonshot AI (Kimi K2.5)", - groupHint: "Kimi K2.5 + Kimi Coding", + groupHint: "Kimi K2.5 + Kimi", }, }), ], diff --git a/extensions/kimi-coding/onboard.ts b/extensions/kimi-coding/onboard.ts index 866780ddaaa..5b1102b8ec1 100644 --- a/extensions/kimi-coding/onboard.ts +++ b/extensions/kimi-coding/onboard.ts @@ -15,7 +15,7 @@ export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig const models = { ...cfg.agents?.defaults?.models }; models[KIMI_CODING_MODEL_REF] = { ...models[KIMI_CODING_MODEL_REF], - alias: models[KIMI_CODING_MODEL_REF]?.alias ?? "Kimi for Coding", + alias: models[KIMI_CODING_MODEL_REF]?.alias ?? "Kimi", }; const defaultModel = buildKimiCodingProvider().models[0]; diff --git a/extensions/kimi-coding/openclaw.plugin.json b/extensions/kimi-coding/openclaw.plugin.json index c86d7211031..a9ee5c991ca 100644 --- a/extensions/kimi-coding/openclaw.plugin.json +++ b/extensions/kimi-coding/openclaw.plugin.json @@ -9,14 +9,14 @@ "provider": "kimi-coding", "method": "api-key", "choiceId": "kimi-code-api-key", - "choiceLabel": "Kimi Code API key (subscription)", + "choiceLabel": "Kimi API key (subscription)", "groupId": "moonshot", "groupLabel": "Moonshot AI (Kimi K2.5)", - "groupHint": "Kimi K2.5 + Kimi Coding", + "groupHint": "Kimi K2.5 + Kimi", "optionKey": "kimiCodeApiKey", "cliFlag": "--kimi-code-api-key", "cliOption": "--kimi-code-api-key ", - "cliDescription": "Kimi Coding API key" + "cliDescription": "Kimi API key" } ], "configSchema": { diff --git a/extensions/kimi-coding/provider-catalog.ts b/extensions/kimi-coding/provider-catalog.ts index f570df20777..307fc65f0d1 100644 --- a/extensions/kimi-coding/provider-catalog.ts +++ b/extensions/kimi-coding/provider-catalog.ts @@ -22,7 +22,7 @@ export function buildKimiCodingProvider(): ModelProviderConfig { models: [ { id: KIMI_CODING_DEFAULT_MODEL_ID, - name: "Kimi for Coding", + name: "Kimi", reasoning: true, input: ["text", "image"], cost: KIMI_CODING_DEFAULT_COST, diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 0b92216bdd7..09605ccff85 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -35,7 +35,7 @@ const moonshotPlugin = { providerId: PROVIDER_ID, methodId: "api-key", label: "Kimi API key (.ai)", - hint: "Kimi K2.5 + Kimi Coding", + hint: "Kimi K2.5 + Kimi", optionKey: "moonshotApiKey", flagName: "--moonshot-api-key", envVar: "MOONSHOT_API_KEY", @@ -48,14 +48,14 @@ const moonshotPlugin = { choiceLabel: "Kimi API key (.ai)", groupId: "moonshot", groupLabel: "Moonshot AI (Kimi K2.5)", - groupHint: "Kimi K2.5 + Kimi Coding", + groupHint: "Kimi K2.5 + Kimi", }, }), createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key-cn", label: "Kimi API key (.cn)", - hint: "Kimi K2.5 + Kimi Coding", + hint: "Kimi K2.5 + Kimi", optionKey: "moonshotApiKey", flagName: "--moonshot-api-key", envVar: "MOONSHOT_API_KEY", @@ -68,7 +68,7 @@ const moonshotPlugin = { choiceLabel: "Kimi API key (.cn)", groupId: "moonshot", groupLabel: "Moonshot AI (Kimi K2.5)", - groupHint: "Kimi K2.5 + Kimi Coding", + groupHint: "Kimi K2.5 + Kimi", }, }), ], diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index cad9e255a2b..8577fc479db 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -12,7 +12,7 @@ "choiceLabel": "Kimi API key (.ai)", "groupId": "moonshot", "groupLabel": "Moonshot AI (Kimi K2.5)", - "groupHint": "Kimi K2.5 + Kimi Coding", + "groupHint": "Kimi K2.5 + Kimi", "optionKey": "moonshotApiKey", "cliFlag": "--moonshot-api-key", "cliOption": "--moonshot-api-key ", @@ -25,7 +25,7 @@ "choiceLabel": "Kimi API key (.cn)", "groupId": "moonshot", "groupLabel": "Moonshot AI (Kimi K2.5)", - "groupHint": "Kimi K2.5 + Kimi Coding", + "groupHint": "Kimi K2.5 + Kimi", "optionKey": "moonshotApiKey", "cliFlag": "--moonshot-api-key", "cliOption": "--moonshot-api-key ", diff --git a/src/agents/provider-id.ts b/src/agents/provider-id.ts index 354817e8a96..79daa684534 100644 --- a/src/agents/provider-id.ts +++ b/src/agents/provider-id.ts @@ -15,6 +15,9 @@ export function normalizeProviderId(provider: string): string { if (normalized === "kimi-code") { return "kimi-coding"; } + if (normalized === "kimi") { + return "kimi-coding"; + } if (normalized === "bedrock" || normalized === "aws-bedrock") { return "amazon-bedrock"; } From 77d6274624c22848232ee649134da523eddd371d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:02:11 -0700 Subject: [PATCH 037/128] docs: rename kimi coding package description --- extensions/kimi-coding/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/kimi-coding/package.json b/extensions/kimi-coding/package.json index 738dd1abd1f..e041999065d 100644 --- a/extensions/kimi-coding/package.json +++ b/extensions/kimi-coding/package.json @@ -2,7 +2,7 @@ "name": "@openclaw/kimi-coding-provider", "version": "2026.3.14", "private": true, - "description": "OpenClaw Kimi Coding provider plugin", + "description": "OpenClaw Kimi provider plugin", "type": "module", "openclaw": { "extensions": [ From 2497b8147e85ca9e842ae2ef4c3626c07caa790c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:17:03 -0700 Subject: [PATCH 038/128] refactor: add shared setup sdk subpath --- extensions/bluebubbles/src/setup-core.ts | 13 +++++---- extensions/bluebubbles/src/setup-surface.ts | 16 +++++------ .../discord/src/actions/handle-action.ts | 2 +- extensions/discord/src/monitor/allow-list.ts | 2 +- .../discord/src/monitor/inbound-worker.ts | 2 +- .../src/monitor/model-picker-preferences.ts | 7 ++--- extensions/feishu/src/setup-core.ts | 8 ++++-- extensions/feishu/src/setup-surface.ts | 18 ++++++------ extensions/google/gemini-cli-provider.ts | 2 +- extensions/googlechat/src/setup-core.ts | 7 +++-- extensions/googlechat/src/setup-surface.ts | 20 ++++++------- extensions/matrix/src/setup-core.ts | 9 +++--- extensions/matrix/src/setup-surface.ts | 22 +++++++-------- extensions/msteams/src/setup-core.ts | 3 +- extensions/msteams/src/setup-surface.ts | 17 +++++------ extensions/openai/openai-codex-provider.ts | 2 +- extensions/slack/src/monitor/media.ts | 2 +- extensions/slack/src/monitor/policy.ts | 2 +- extensions/slack/src/shared.ts | 14 ++++++---- extensions/synology-chat/src/setup-surface.ts | 13 +++++---- extensions/telegram/src/channel.ts | 2 +- extensions/tlon/src/setup-core.ts | 11 ++++---- extensions/tlon/src/setup-surface.ts | 8 ++++-- extensions/twitch/src/setup-surface.ts | 14 ++++++---- extensions/whatsapp/src/setup-surface.ts | 1 - extensions/zalo/src/setup-core.ts | 7 +++-- extensions/zalo/src/setup-surface.ts | 17 +++++------ extensions/zalouser/src/setup-core.ts | 7 +++-- extensions/zalouser/src/setup-surface.ts | 17 +++++------ package.json | 28 +++++++++++++++++++ scripts/lib/plugin-sdk-entrypoints.json | 9 +++++- src/plugin-sdk/subpaths.test.ts | 9 ++++++ 32 files changed, 183 insertions(+), 128 deletions(-) diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts index 83a079dbaab..6509c5f240b 100644 --- a/extensions/bluebubbles/src/setup-core.ts +++ b/extensions/bluebubbles/src/setup-core.ts @@ -1,13 +1,14 @@ import { applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, migrateBaseNameToDefaultAccount, patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; -import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + normalizeAccountId, + setTopLevelChannelDmPolicyWithAllowFrom, + type ChannelSetupAdapter, + type DmPolicy, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; const channel = "bluebubbles" as const; diff --git a/extensions/bluebubbles/src/setup-surface.ts b/extensions/bluebubbles/src/setup-surface.ts index 1a138b8e73d..f6922ed4861 100644 --- a/extensions/bluebubbles/src/setup-surface.ts +++ b/extensions/bluebubbles/src/setup-surface.ts @@ -1,14 +1,14 @@ import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, mergeAllowFromEntries, resolveSetupAccountId, -} 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 type { DmPolicy } from "../../../src/config/types.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 ChannelSetupDmPolicy, + type ChannelSetupWizard, + type DmPolicy, + type OpenClawConfig, + type WizardPrompter, +} from "openclaw/plugin-sdk/setup"; import { listBlueBubblesAccountIds, resolveBlueBubblesAccount, diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index 4beb7d76de4..c938d675955 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -1,4 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { readNumberParam, readStringArrayParam, @@ -9,7 +10,6 @@ import { handleDiscordAction } from "../../../../src/agents/tools/discord-action import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js"; import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; import { normalizeInteractiveReply } from "../../../../src/interactive/payload.js"; -import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js"; import { buildDiscordInteractiveComponents } from "../shared-interactive.js"; import { resolveDiscordChannelId } from "../targets.js"; import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; diff --git a/extensions/discord/src/monitor/allow-list.ts b/extensions/discord/src/monitor/allow-list.ts index 6391ad5c3a5..a6208eaf63a 100644 --- a/extensions/discord/src/monitor/allow-list.ts +++ b/extensions/discord/src/monitor/allow-list.ts @@ -1,4 +1,5 @@ import type { Guild, User } from "@buape/carbon"; +import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import type { AllowlistMatch } from "../../../../src/channels/allowlist-match.js"; import { buildChannelKeyCandidates, @@ -6,7 +7,6 @@ import { resolveChannelMatchConfig, type ChannelMatchSource, } from "../../../../src/channels/channel-config.js"; -import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js"; import { formatDiscordUserTag } from "./format.js"; export type DiscordAllowList = { diff --git a/extensions/discord/src/monitor/inbound-worker.ts b/extensions/discord/src/monitor/inbound-worker.ts index 214eb6a8020..cbc8e246704 100644 --- a/extensions/discord/src/monitor/inbound-worker.ts +++ b/extensions/discord/src/monitor/inbound-worker.ts @@ -1,7 +1,7 @@ +import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import { createRunStateMachine } from "../../../../src/channels/run-state-machine.js"; import { danger } from "../../../../src/globals.js"; import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts"; -import { KeyedAsyncQueue } from "../../../../src/plugin-sdk/keyed-async-queue.js"; import { materializeDiscordInboundJob, type DiscordInboundJob } from "./inbound-job.js"; import type { RuntimeEnv } from "./message-handler.preflight.types.js"; import { processDiscordMessage } from "./message-handler.process.js"; diff --git a/extensions/discord/src/monitor/model-picker-preferences.ts b/extensions/discord/src/monitor/model-picker-preferences.ts index e75ce013403..8657ed66436 100644 --- a/extensions/discord/src/monitor/model-picker-preferences.ts +++ b/extensions/discord/src/monitor/model-picker-preferences.ts @@ -1,14 +1,11 @@ import os from "node:os"; import path from "node:path"; +import { normalizeAccountId as normalizeSharedAccountId } from "openclaw/plugin-sdk/account-id"; +import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store"; import { normalizeProviderId } from "../../../../src/agents/model-selection.js"; import { resolveStateDir } from "../../../../src/config/paths.js"; import { withFileLock } from "../../../../src/infra/file-lock.js"; import { resolveRequiredHomeDir } from "../../../../src/infra/home-dir.js"; -import { - readJsonFileWithFallback, - writeJsonFileAtomically, -} from "../../../../src/plugin-sdk/json-store.js"; -import { normalizeAccountId as normalizeSharedAccountId } from "../../../../src/routing/account-id.js"; const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = { retries: { diff --git a/extensions/feishu/src/setup-core.ts b/extensions/feishu/src/setup-core.ts index ada8ef79933..a9c6639a2f7 100644 --- a/extensions/feishu/src/setup-core.ts +++ b/extensions/feishu/src/setup-core.ts @@ -1,6 +1,8 @@ -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { + DEFAULT_ACCOUNT_ID, + type ChannelSetupAdapter, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import type { FeishuConfig } from "./types.js"; export function setFeishuNamedAccountEnabled( diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 4f92b07a804..e990f308624 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -1,20 +1,20 @@ import { buildSingleChannelSecretPromptState, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + hasConfiguredSecretInput, mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, 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 type { DmPolicy } from "../../../src/config/types.js"; -import type { SecretInput } from "../../../src/config/types.secrets.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type DmPolicy, + type OpenClawConfig, + type SecretInput, +} from "openclaw/plugin-sdk/setup"; import { listFeishuAccountIds, resolveFeishuCredentials } from "./accounts.js"; import { probeFeishu } from "./probe.js"; import { feishuSetupAdapter } from "./setup-core.js"; diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index 926913f7390..e235a0dfebc 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -1,5 +1,5 @@ +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/core"; import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js"; -import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; import type { OpenClawPluginApi, ProviderAuthContext, diff --git a/extensions/googlechat/src/setup-core.ts b/extensions/googlechat/src/setup-core.ts index d4d2de49e06..b12d2704b2d 100644 --- a/extensions/googlechat/src/setup-core.ts +++ b/extensions/googlechat/src/setup-core.ts @@ -1,10 +1,11 @@ import { applyAccountNameToChannelSection, applySetupAccountConfigPatch, + DEFAULT_ACCOUNT_ID, migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + normalizeAccountId, + type ChannelSetupAdapter, +} from "openclaw/plugin-sdk/setup"; const channel = "googlechat" as const; diff --git a/extensions/googlechat/src/setup-surface.ts b/extensions/googlechat/src/setup-surface.ts index 5561989543f..0af6e3d4f54 100644 --- a/extensions/googlechat/src/setup-surface.ts +++ b/extensions/googlechat/src/setup-surface.ts @@ -1,19 +1,17 @@ -import { - applySetupAccountConfigPatch, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import { addWildcardAllowFrom, + applySetupAccountConfigPatch, + DEFAULT_ACCOUNT_ID, + formatDocsLink, mergeAllowFromEntries, + migrateBaseNameToDefaultAccount, setTopLevelChannelDmPolicyWithAllowFrom, 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 type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type DmPolicy, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId, diff --git a/extensions/matrix/src/setup-core.ts b/extensions/matrix/src/setup-core.ts index f0fc395a344..d78049262a1 100644 --- a/extensions/matrix/src/setup-core.ts +++ b/extensions/matrix/src/setup-core.ts @@ -1,10 +1,11 @@ import { applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { normalizeSecretInputString } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + normalizeAccountId, + normalizeSecretInputString, + type ChannelSetupAdapter, +} from "openclaw/plugin-sdk/setup"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index 0f79545358e..09e9438a410 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,20 +1,20 @@ import { addWildcardAllowFrom, buildSingleChannelSecretPromptState, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + formatResolvedUnresolvedNote, + hasConfiguredSecretInput, mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelGroupPolicy, -} 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 type { DmPolicy } from "../../../src/config/types.js"; -import type { SecretInput } from "../../../src/config/types.secrets.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.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 ChannelSetupDmPolicy, + type ChannelSetupWizard, + type DmPolicy, + type OpenClawConfig, + type SecretInput, + type WizardPrompter, +} from "openclaw/plugin-sdk/setup"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; diff --git a/extensions/msteams/src/setup-core.ts b/extensions/msteams/src/setup-core.ts index 74079aaf389..fb4246a8d0a 100644 --- a/extensions/msteams/src/setup-core.ts +++ b/extensions/msteams/src/setup-core.ts @@ -1,5 +1,4 @@ -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID, type ChannelSetupAdapter } from "openclaw/plugin-sdk/setup"; export const msteamsSetupAdapter: ChannelSetupAdapter = { resolveAccountId: () => DEFAULT_ACCOUNT_ID, diff --git a/extensions/msteams/src/setup-surface.ts b/extensions/msteams/src/setup-surface.ts index e3bc6169f6c..185bf3d7362 100644 --- a/extensions/msteams/src/setup-surface.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -1,17 +1,18 @@ +import type { MSTeamsTeamConfig } from "openclaw/plugin-sdk/msteams"; import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, mergeAllowFromEntries, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, 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 type { DmPolicy, MSTeamsTeamConfig } from "../../../src/config/types.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 ChannelSetupDmPolicy, + type ChannelSetupWizard, + type DmPolicy, + type OpenClawConfig, + type WizardPrompter, +} from "openclaw/plugin-sdk/setup"; import { parseMSTeamsTeamEntry, resolveMSTeamsChannelAllowlist, diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 49c6f7272a9..e8be8bd4eb1 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -4,6 +4,7 @@ import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/core"; import { CODEX_CLI_PROFILE_ID } from "../../src/agents/auth-profiles.js"; import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles.js"; import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"; @@ -13,7 +14,6 @@ import { normalizeModelCompat } from "../../src/agents/model-compat.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 { diff --git a/extensions/slack/src/monitor/media.ts b/extensions/slack/src/monitor/media.ts index 7c5a619129f..ef494f2e48c 100644 --- a/extensions/slack/src/monitor/media.ts +++ b/extensions/slack/src/monitor/media.ts @@ -1,9 +1,9 @@ import type { WebClient as SlackWebClient } from "@slack/web-api"; +import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url"; import { normalizeHostname } from "../../../../src/infra/net/hostname.js"; import type { FetchLike } from "../../../../src/media/fetch.js"; import { fetchRemoteMedia } from "../../../../src/media/fetch.js"; import { saveMediaBuffer } from "../../../../src/media/store.js"; -import { resolveRequestUrl } from "../../../../src/plugin-sdk/request-url.js"; import type { SlackAttachment, SlackFile } from "../types.js"; function isSlackHostname(hostname: string): boolean { diff --git a/extensions/slack/src/monitor/policy.ts b/extensions/slack/src/monitor/policy.ts index ab5d9230a62..9f58c758c51 100644 --- a/extensions/slack/src/monitor/policy.ts +++ b/extensions/slack/src/monitor/policy.ts @@ -1,4 +1,4 @@ -import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js"; +import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access"; export function isSlackChannelAllowedByPolicy(params: { groupPolicy: "open" | "disabled" | "allowlist"; diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 7345de3a22c..de7238a7a78 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -1,12 +1,14 @@ -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 { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, -} from "../../../src/plugin-sdk/channel-config-helpers.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { + formatDocsLink, + hasConfiguredSecretInput, + patchChannelConfigForAccount, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts index d998022365b..7985199eda6 100644 --- a/extensions/synology-chat/src/setup-surface.ts +++ b/extensions/synology-chat/src/setup-surface.ts @@ -1,13 +1,14 @@ import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, mergeAllowFromEntries, + normalizeAccountId, setSetupChannelEnabled, splitSetupEntries, -} from "../../../src/channels/plugins/setup-wizard-helpers.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"; + type ChannelSetupAdapter, + type ChannelSetupWizard, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import { listAccountIds, resolveAccount } from "./accounts.js"; import type { SynologyChatAccountRaw, SynologyChatChannelConfig } from "./types.js"; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 720bc2985b7..d73e63b0996 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -14,7 +14,6 @@ import { import { buildAgentSessionKey, resolveThreadSessionKeys, - type ChannelPlugin, type RoutePeer, } from "../../../src/plugin-sdk-internal/core.js"; import { @@ -31,6 +30,7 @@ import { resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, TelegramConfigSchema, + type ChannelPlugin, type ChannelMessageActionAdapter, type OpenClawConfig, } from "../../../src/plugin-sdk-internal/telegram.js"; diff --git a/extensions/tlon/src/setup-core.ts b/extensions/tlon/src/setup-core.ts index a237a813edf..ae95819af52 100644 --- a/extensions/tlon/src/setup-core.ts +++ b/extensions/tlon/src/setup-core.ts @@ -1,11 +1,12 @@ import { applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + type ChannelSetupAdapter, + type ChannelSetupInput, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import { buildTlonAccountFields } from "./account-fields.js"; import { resolveTlonAccount } from "./types.js"; diff --git a/extensions/tlon/src/setup-surface.ts b/extensions/tlon/src/setup-surface.ts index ec6258277bd..e3c1b43f0c1 100644 --- a/extensions/tlon/src/setup-surface.ts +++ b/extensions/tlon/src/setup-surface.ts @@ -1,6 +1,8 @@ -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, + type ChannelSetupWizard, +} from "openclaw/plugin-sdk/setup"; import { applyTlonSetupConfig, type TlonSetupInput, tlonSetupAdapter } from "./setup-core.js"; import { normalizeShip } from "./targets.js"; import { listTlonAccountIds, resolveTlonAccount, type TlonResolvedAccount } from "./types.js"; diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index 3113bfd9e3b..ec8a7e741b4 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -2,12 +2,14 @@ * Twitch setup wizard surface for CLI setup. */ -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 { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + formatDocsLink, + type ChannelSetupAdapter, + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type OpenClawConfig, + type WizardPrompter, +} from "openclaw/plugin-sdk/setup"; import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import type { TwitchAccountConfig, TwitchRole } from "./types.js"; import { isAccountConfigured } from "./utils/twitch.js"; diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 41204ecfcb9..805bd7eb397 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import type { DmPolicy } from "openclaw/plugin-sdk/whatsapp"; import { DEFAULT_ACCOUNT_ID, formatCliCommand, diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts index 6e194a41652..fd6d09449ad 100644 --- a/extensions/zalo/src/setup-core.ts +++ b/extensions/zalo/src/setup-core.ts @@ -1,10 +1,11 @@ import { applyAccountNameToChannelSection, applySetupAccountConfigPatch, + DEFAULT_ACCOUNT_ID, migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + normalizeAccountId, + type ChannelSetupAdapter, +} from "openclaw/plugin-sdk/setup"; const channel = "zalo" as const; diff --git a/extensions/zalo/src/setup-surface.ts b/extensions/zalo/src/setup-surface.ts index 6ae6a78be0f..50e6761b35a 100644 --- a/extensions/zalo/src/setup-surface.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -1,17 +1,18 @@ import { buildSingleChannelSecretPromptState, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + hasConfiguredSecretInput, mergeAllowFromEntries, + normalizeAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, -} 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 type { SecretInput } from "../../../src/config/types.secrets.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"; + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type OpenClawConfig, + type SecretInput, +} from "openclaw/plugin-sdk/setup"; import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js"; import { zaloSetupAdapter } from "./setup-core.js"; diff --git a/extensions/zalouser/src/setup-core.ts b/extensions/zalouser/src/setup-core.ts index 45f412ed9f6..9e66e2c63c6 100644 --- a/extensions/zalouser/src/setup-core.ts +++ b/extensions/zalouser/src/setup-core.ts @@ -1,10 +1,11 @@ import { applyAccountNameToChannelSection, applySetupAccountConfigPatch, + DEFAULT_ACCOUNT_ID, migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + normalizeAccountId, + type ChannelSetupAdapter, +} from "openclaw/plugin-sdk/setup"; const channel = "zalouser" as const; diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index 74f940e5077..f51b55ff068 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -1,14 +1,15 @@ -import { patchScopedAccountConfig } from "../../../src/channels/plugins/setup-helpers.js"; import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, + formatResolvedUnresolvedNote, mergeAllowFromEntries, + normalizeAccountId, + patchScopedAccountConfig, setTopLevelChannelDmPolicyWithAllowFrom, -} 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 { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import { listZalouserAccountIds, resolveDefaultZalouserAccountId, diff --git a/package.json b/package.json index eaae91d6a40..95763eb8a0f 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,10 @@ "types": "./dist/plugin-sdk/routing.d.ts", "default": "./dist/plugin-sdk/routing.js" }, + "./plugin-sdk/setup": { + "types": "./dist/plugin-sdk/setup.d.ts", + "default": "./dist/plugin-sdk/setup.js" + }, "./plugin-sdk/telegram": { "types": "./dist/plugin-sdk/telegram.d.ts", "default": "./dist/plugin-sdk/telegram.js" @@ -226,10 +230,34 @@ "types": "./dist/plugin-sdk/account-id.d.ts", "default": "./dist/plugin-sdk/account-id.js" }, + "./plugin-sdk/allow-from": { + "types": "./dist/plugin-sdk/allow-from.d.ts", + "default": "./dist/plugin-sdk/allow-from.js" + }, + "./plugin-sdk/boolean-param": { + "types": "./dist/plugin-sdk/boolean-param.d.ts", + "default": "./dist/plugin-sdk/boolean-param.js" + }, + "./plugin-sdk/channel-config-helpers": { + "types": "./dist/plugin-sdk/channel-config-helpers.d.ts", + "default": "./dist/plugin-sdk/channel-config-helpers.js" + }, + "./plugin-sdk/group-access": { + "types": "./dist/plugin-sdk/group-access.d.ts", + "default": "./dist/plugin-sdk/group-access.js" + }, + "./plugin-sdk/json-store": { + "types": "./dist/plugin-sdk/json-store.d.ts", + "default": "./dist/plugin-sdk/json-store.js" + }, "./plugin-sdk/keyed-async-queue": { "types": "./dist/plugin-sdk/keyed-async-queue.d.ts", "default": "./dist/plugin-sdk/keyed-async-queue.js" }, + "./plugin-sdk/request-url": { + "types": "./dist/plugin-sdk/request-url.d.ts", + "default": "./dist/plugin-sdk/request-url.js" + }, "./cli-entry": "./openclaw.mjs" }, "scripts": { diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index a6de3f4e24e..f99be019a69 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -7,6 +7,7 @@ "sandbox", "self-hosted-provider-setup", "routing", + "setup", "telegram", "discord", "slack", @@ -46,5 +47,11 @@ "zalo", "zalouser", "account-id", - "keyed-async-queue" + "allow-from", + "boolean-param", + "channel-config-helpers", + "group-access", + "json-store", + "keyed-async-queue", + "request-url" ] diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 7b15bcfce97..d7d15f88748 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -14,6 +14,7 @@ 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 setupSdk from "openclaw/plugin-sdk/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"; @@ -63,6 +64,14 @@ describe("plugin-sdk subpath exports", () => { ); }); + it("exports shared setup helpers from the dedicated subpath", () => { + expect(typeof setupSdk.DEFAULT_ACCOUNT_ID).toBe("string"); + expect(typeof setupSdk.formatDocsLink).toBe("function"); + expect(typeof setupSdk.mergeAllowFromEntries).toBe("function"); + expect(typeof setupSdk.setTopLevelChannelDmPolicyWithAllowFrom).toBe("function"); + expect(typeof setupSdk.formatResolvedUnresolvedNote).toBe("function"); + }); + it("exports narrow self-hosted provider setup helpers", () => { expect(typeof selfHostedProviderSetupSdk.buildVllmProvider).toBe("function"); expect(typeof selfHostedProviderSetupSdk.buildSglangProvider).toBe("function"); From a71c61122d41a86bb16aa62780ec30bfed611c7c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:17:45 -0700 Subject: [PATCH 039/128] refactor: add plugin sdk setup entrypoint --- src/plugin-sdk/setup.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/plugin-sdk/setup.ts diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts new file mode 100644 index 00000000000..e77af2904c3 --- /dev/null +++ b/src/plugin-sdk/setup.ts @@ -0,0 +1,37 @@ +// Shared setup wizard/types/helpers for extension setup surfaces and adapters. + +export type { OpenClawConfig } from "../config/config.js"; +export type { DmPolicy, GroupPolicy } from "../config/types.js"; +export type { SecretInput } from "../config/types.secrets.js"; +export type { WizardPrompter } from "../wizard/prompts.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; +export type { ChannelSetupInput } from "../channels/plugins/types.core.js"; +export type { ChannelSetupDmPolicy } from "../channels/plugins/setup-wizard-types.js"; +export type { ChannelSetupWizard } from "../channels/plugins/setup-wizard.js"; + +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +export { formatDocsLink } from "../terminal/links.js"; +export { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; + +export { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, + patchScopedAccountConfig, +} from "../channels/plugins/setup-helpers.js"; +export { + addWildcardAllowFrom, + buildSingleChannelSecretPromptState, + mergeAllowFromEntries, + patchChannelConfigForAccount, + promptSingleChannelSecretInput, + resolveSetupAccountId, + runSingleChannelSecretStep, + setSetupChannelEnabled, + setTopLevelChannelAllowFrom, + setTopLevelChannelDmPolicyWithAllowFrom, + setTopLevelChannelGroupPolicy, + splitSetupEntries, +} from "../channels/plugins/setup-wizard-helpers.js"; + +export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; From 622f13253bc43a531ff5b1dbc737aaf87bac26da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:23:58 -0700 Subject: [PATCH 040/128] feat(tts): add microsoft voice listing --- extensions/talk-voice/index.test.ts | 189 ++++++++++++++++++++++++++++ scripts/docs-i18n/util_test.go | 9 ++ src/tts/providers/microsoft.test.ts | 60 +++++++++ src/tts/providers/microsoft.ts | 66 ++++++++++ src/types/node-edge-tts.d.ts | 6 + 5 files changed, 330 insertions(+) create mode 100644 extensions/talk-voice/index.test.ts create mode 100644 src/tts/providers/microsoft.test.ts diff --git a/extensions/talk-voice/index.test.ts b/extensions/talk-voice/index.test.ts new file mode 100644 index 00000000000..6f945e9dd0a --- /dev/null +++ b/extensions/talk-voice/index.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawPluginCommandDefinition } from "../../src/plugins/types.js"; +import { createPluginRuntimeMock } from "../test-utils/plugin-runtime-mock.js"; +import register from "./index.js"; + +function createHarness(config: Record) { + let command: OpenClawPluginCommandDefinition | undefined; + const runtime = createPluginRuntimeMock({ + config: { + loadConfig: vi.fn(() => config), + writeConfigFile: vi.fn().mockResolvedValue(undefined), + }, + tts: { + listVoices: vi.fn(), + }, + }); + const api = { + runtime, + registerCommand: vi.fn((definition: OpenClawPluginCommandDefinition) => { + command = definition; + }), + }; + register(api as never); + if (!command) { + throw new Error("talk-voice command not registered"); + } + return { command, runtime }; +} + +function createCommandContext(args: string, channel: string = "discord") { + return { + args, + channel, + channelId: channel, + isAuthorizedSender: true, + commandBody: args ? `/voice ${args}` : "/voice", + config: {}, + requestConversationBinding: vi.fn(), + detachConversationBinding: vi.fn(), + getCurrentConversationBinding: vi.fn(), + }; +} + +describe("talk-voice plugin", () => { + it("reports active provider status", async () => { + const { command } = createHarness({ + talk: { + provider: "microsoft", + providers: { + microsoft: { + voiceId: "en-US-AvaNeural", + apiKey: "secret-token", + }, + }, + }, + }); + + const result = await command.handler(createCommandContext("")); + + expect(result).toEqual({ + text: + "Talk voice status:\n" + + "- provider: microsoft\n" + + "- talk.voiceId: en-US-AvaNeural\n" + + "- microsoft.apiKey: secret…", + }); + }); + + it("lists voices from the active provider", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + baseUrl: "https://voices.example.test", + }, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([ + { id: "voice-a", name: "Claudia", category: "general" }, + { id: "voice-b", name: "Bert" }, + ]); + + const result = await command.handler(createCommandContext("list 1")); + + expect(runtime.tts.listVoices).toHaveBeenCalledWith({ + provider: "elevenlabs", + cfg: { + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + baseUrl: "https://voices.example.test", + }, + }, + }, + }, + apiKey: "sk-eleven", + baseUrl: "https://voices.example.test", + }); + expect(result).toEqual({ + text: + "ElevenLabs voices: 2\n\n" + + "- Claudia · general\n" + + " id: voice-a\n\n" + + "(showing first 1)", + }); + }); + + it("writes canonical talk provider config and legacy elevenlabs voice id", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + }, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); + + const result = await command.handler(createCommandContext("set Claudia")); + + expect(runtime.config.writeConfigFile).toHaveBeenCalledWith({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + voiceId: "voice-a", + }, + }, + voiceId: "voice-a", + }, + }); + expect(result).toEqual({ + text: "✅ ElevenLabs Talk voice set to Claudia\nvoice-a", + }); + }); + + it("writes provider voice id without legacy top-level field for microsoft", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "microsoft", + providers: { + microsoft: {}, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "en-US-AvaNeural", name: "Ava" }]); + + await command.handler(createCommandContext("set Ava")); + + expect(runtime.config.writeConfigFile).toHaveBeenCalledWith({ + talk: { + provider: "microsoft", + providers: { + microsoft: { + voiceId: "en-US-AvaNeural", + }, + }, + }, + }); + }); + + it("returns provider lookup errors cleanly", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "microsoft", + providers: { + microsoft: {}, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockRejectedValue( + new Error("speech provider microsoft does not support voice listing"), + ); + + const result = await command.handler(createCommandContext("list")); + + expect(result).toEqual({ + text: "Microsoft voice list failed: speech provider microsoft does not support voice listing", + }); + }); +}); diff --git a/scripts/docs-i18n/util_test.go b/scripts/docs-i18n/util_test.go index 77b5ca82a73..30dcb14a07d 100644 --- a/scripts/docs-i18n/util_test.go +++ b/scripts/docs-i18n/util_test.go @@ -31,6 +31,15 @@ func TestDocsPiModelUsesProviderDefault(t *testing.T) { } } +func TestDocsPiModelKeepsOpenAIDefaultAtGPT54(t *testing.T) { + t.Setenv(envDocsI18nProvider, "openai") + t.Setenv(envDocsI18nModel, "") + + if got := docsPiModel(); got != defaultOpenAIModel { + t.Fatalf("expected OpenAI default model %q, got %q", defaultOpenAIModel, got) + } +} + func TestDocsPiModelPrefersExplicitOverride(t *testing.T) { t.Setenv(envDocsI18nProvider, "openai") t.Setenv(envDocsI18nModel, "gpt-5.2") diff --git a/src/tts/providers/microsoft.test.ts b/src/tts/providers/microsoft.test.ts new file mode 100644 index 00000000000..fa82456be00 --- /dev/null +++ b/src/tts/providers/microsoft.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { listMicrosoftVoices } from "./microsoft.js"; + +describe("listMicrosoftVoices", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("maps Microsoft voice metadata into speech voice options", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify([ + { + ShortName: "en-US-AvaNeural", + FriendlyName: "Microsoft Ava Online (Natural) - English (United States)", + Locale: "en-US", + Gender: "Female", + VoiceTag: { + ContentCategories: ["General"], + VoicePersonalities: ["Friendly", "Positive"], + }, + }, + ]), + { status: 200 }, + ), + ) as typeof globalThis.fetch; + + const voices = await listMicrosoftVoices(); + + expect(voices).toEqual([ + { + id: "en-US-AvaNeural", + name: "Microsoft Ava Online (Natural) - English (United States)", + category: "General", + description: "en-US · Female · Friendly, Positive", + }, + ]); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining("/voices/list?trustedclienttoken="), + expect.objectContaining({ + headers: expect.objectContaining({ + Origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold", + "Sec-MS-GEC": expect.any(String), + "Sec-MS-GEC-Version": expect.stringContaining("1-"), + }), + }), + ); + }); + + it("throws on Microsoft voice list failures", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response("nope", { status: 503 })) as typeof globalThis.fetch; + + await expect(listMicrosoftVoices()).rejects.toThrow("Microsoft voices API error (503)"); + }); +}); diff --git a/src/tts/providers/microsoft.ts b/src/tts/providers/microsoft.ts index ee31e35a204..06958931ad8 100644 --- a/src/tts/providers/microsoft.ts +++ b/src/tts/providers/microsoft.ts @@ -1,17 +1,83 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import path from "node:path"; +import { + CHROMIUM_FULL_VERSION, + TRUSTED_CLIENT_TOKEN, + generateSecMsGecToken, +} from "node-edge-tts/dist/drm.js"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; import { isVoiceCompatibleAudio } from "../../media/audio.js"; import type { SpeechProviderPlugin } from "../../plugins/types.js"; +import type { SpeechVoiceOption } from "../provider-types.js"; import { edgeTTS, inferEdgeExtension } from "../tts-core.js"; const DEFAULT_EDGE_OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3"; +type MicrosoftVoiceListEntry = { + ShortName?: string; + FriendlyName?: string; + Locale?: string; + Gender?: string; + VoiceTag?: { + ContentCategories?: string[]; + VoicePersonalities?: string[]; + }; +}; + +function buildMicrosoftVoiceHeaders(): Record { + const major = CHROMIUM_FULL_VERSION.split(".")[0] || "0"; + return { + Authority: "speech.platform.bing.com", + Origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold", + Accept: "*/*", + "User-Agent": + `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ` + + `(KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36 Edg/${major}.0.0.0`, + "Sec-MS-GEC": generateSecMsGecToken(), + "Sec-MS-GEC-Version": `1-${CHROMIUM_FULL_VERSION}`, + }; +} + +function formatMicrosoftVoiceDescription(entry: MicrosoftVoiceListEntry): string | undefined { + const parts = [entry.Locale, entry.Gender]; + const personalities = entry.VoiceTag?.VoicePersonalities?.filter(Boolean) ?? []; + if (personalities.length > 0) { + parts.push(personalities.join(", ")); + } + const filtered = parts.filter((part): part is string => Boolean(part?.trim())); + return filtered.length > 0 ? filtered.join(" · ") : undefined; +} + +export async function listMicrosoftVoices(): Promise { + const response = await fetch( + "https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list" + + `?trustedclienttoken=${TRUSTED_CLIENT_TOKEN}`, + { + headers: buildMicrosoftVoiceHeaders(), + }, + ); + if (!response.ok) { + throw new Error(`Microsoft voices API error (${response.status})`); + } + const voices = (await response.json()) as MicrosoftVoiceListEntry[]; + return Array.isArray(voices) + ? voices + .map((voice) => ({ + id: voice.ShortName?.trim() ?? "", + name: voice.FriendlyName?.trim() || voice.ShortName?.trim() || undefined, + category: voice.VoiceTag?.ContentCategories?.find((value) => value.trim().length > 0), + description: formatMicrosoftVoiceDescription(voice), + })) + .filter((voice) => voice.id.length > 0) + : []; +} + export function buildMicrosoftSpeechProvider(): SpeechProviderPlugin { return { id: "microsoft", label: "Microsoft", aliases: ["edge"], + listVoices: async () => await listMicrosoftVoices(), isConfigured: ({ config }) => config.edge.enabled, synthesize: async (req) => { const tempRoot = resolvePreferredOpenClawTmpDir(); diff --git a/src/types/node-edge-tts.d.ts b/src/types/node-edge-tts.d.ts index eaaaa9cdf5a..b800c986cb8 100644 --- a/src/types/node-edge-tts.d.ts +++ b/src/types/node-edge-tts.d.ts @@ -16,3 +16,9 @@ declare module "node-edge-tts" { ttsPromise(text: string, outputPath: string): Promise; } } + +declare module "node-edge-tts/dist/drm.js" { + export const CHROMIUM_FULL_VERSION: string; + export const TRUSTED_CLIENT_TOKEN: string; + export function generateSecMsGecToken(): string; +} From 5602973b5df0b19961c0bd58eb25de7eb00a5646 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:24:07 -0700 Subject: [PATCH 041/128] docs(plugins): add capability contract example --- docs/tools/plugin.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 8b8de658785..8b4d389cd30 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -167,6 +167,18 @@ For example, TTS follows this shape: That same pattern should be preferred for future capabilities. +### Capability example: video + +If OpenClaw adds video, prefer this order: + +1. define a core video capability +2. decide the shared contract: input media shape, provider result shape, cache/fallback behavior, and runtime helpers +3. let vendor plugins such as `openai` or a future video vendor register video implementations +4. let channels or feature plugins consume `api.runtime.video` instead of wiring directly to a provider plugin + +This avoids baking one provider's video assumptions into core. The plugin owns +the vendor surface; core owns the capability contract. + ## Compatible bundles OpenClaw also recognizes two compatible external bundle layouts: From 5f5b409fe9bbc1bdc72ad5fe430a2e0bdc7e4b96 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 17 Mar 2026 08:55:41 +0530 Subject: [PATCH 042/128] fix: remove duplicate whatsapp dm policy import --- extensions/whatsapp/src/setup-surface.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 805bd7eb397..50a28d419cb 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -12,7 +12,6 @@ import { type OpenClawConfig, } from "../../../src/plugin-sdk-internal/setup.js"; import type { ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; -import { type DmPolicy } from "../../../src/plugin-sdk-internal/whatsapp.js"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; import { loginWeb } from "./login.js"; import { whatsappSetupAdapter } from "./setup-core.js"; From 57f1ab1fca1a5b7442afb237c076bdc8488200f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:27:25 -0700 Subject: [PATCH 043/128] feat(tts): enrich speech voice metadata --- extensions/talk-voice/index.test.ts | 33 +++++++++++++++++++ extensions/talk-voice/index.ts | 18 ++++++++++ .../contracts/registry.contract.test.ts | 14 ++++++++ src/tts/provider-types.ts | 3 ++ src/tts/providers/microsoft.test.ts | 5 ++- src/tts/providers/microsoft.ts | 12 +++---- 6 files changed, 78 insertions(+), 7 deletions(-) diff --git a/extensions/talk-voice/index.test.ts b/extensions/talk-voice/index.test.ts index 6f945e9dd0a..2d0a991aa47 100644 --- a/extensions/talk-voice/index.test.ts +++ b/extensions/talk-voice/index.test.ts @@ -110,6 +110,39 @@ describe("talk-voice plugin", () => { }); }); + it("surfaces richer provider voice metadata when available", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "microsoft", + providers: { + microsoft: {}, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([ + { + id: "en-US-AvaNeural", + name: "Ava", + category: "General", + locale: "en-US", + gender: "Female", + personalities: ["Friendly", "Positive"], + description: "Friendly, Positive", + }, + ]); + + const result = await command.handler(createCommandContext("list")); + + expect(result).toEqual({ + text: + "Microsoft voices: 1\n\n" + + "- Ava · General\n" + + " id: en-US-AvaNeural\n" + + " meta: en-US · Female · Friendly, Positive\n" + + " note: Friendly, Positive", + }); + }); + it("writes canonical talk provider config and legacy elevenlabs voice id", async () => { const { command, runtime } = createHarness({ talk: { diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index 3c8ee3ba09e..8f698262e3e 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -31,6 +31,16 @@ function resolveProviderLabel(providerId: string): string { } } +function formatVoiceMeta(voice: SpeechVoiceOption): string | undefined { + const parts = [voice.locale, voice.gender]; + const personalities = voice.personalities?.filter((value) => value.trim().length > 0) ?? []; + if (personalities.length > 0) { + parts.push(personalities.join(", ")); + } + const filtered = parts.filter((part): part is string => Boolean(part?.trim())); + return filtered.length > 0 ? filtered.join(" · ") : undefined; +} + function formatVoiceList(voices: SpeechVoiceOption[], limit: number, providerId: string): string { const sliced = voices.slice(0, Math.max(1, Math.min(limit, 50))); const lines: string[] = []; @@ -42,6 +52,14 @@ function formatVoiceList(voices: SpeechVoiceOption[], limit: number, providerId: const meta = category ? ` · ${category}` : ""; lines.push(`- ${name}${meta}`); lines.push(` id: ${v.id}`); + const details = formatVoiceMeta(v); + if (details) { + lines.push(` meta: ${details}`); + } + const description = (v.description ?? "").trim(); + if (description) { + lines.push(` note: ${description}`); + } } if (voices.length > sliced.length) { lines.push(""); diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index cf728b9a91b..48da6c3d9a1 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -27,6 +27,14 @@ function findSpeechProviderIdsForPlugin(pluginId: string) { .toSorted((left, right) => left.localeCompare(right)); } +function findSpeechProviderForPlugin(pluginId: string) { + const entry = speechProviderContractRegistry.find((candidate) => candidate.pluginId === pluginId); + if (!entry) { + throw new Error(`speech provider contract missing for ${pluginId}`); + } + return entry.provider; +} + function findRegistrationForPlugin(pluginId: string) { const entry = pluginRegistrationContractRegistry.find( (candidate) => candidate.pluginId === pluginId, @@ -97,4 +105,10 @@ describe("plugin contract registry", () => { speechProviderIds: ["microsoft"], }); }); + + it("keeps bundled speech voice-list support explicit", () => { + expect(findSpeechProviderForPlugin("openai").listVoices).toEqual(expect.any(Function)); + expect(findSpeechProviderForPlugin("elevenlabs").listVoices).toEqual(expect.any(Function)); + expect(findSpeechProviderForPlugin("microsoft").listVoices).toEqual(expect.any(Function)); + }); }); diff --git a/src/tts/provider-types.ts b/src/tts/provider-types.ts index be0a083127d..c0640b63614 100644 --- a/src/tts/provider-types.ts +++ b/src/tts/provider-types.ts @@ -42,6 +42,9 @@ export type SpeechVoiceOption = { name?: string; category?: string; description?: string; + locale?: string; + gender?: string; + personalities?: string[]; }; export type SpeechListVoicesRequest = { diff --git a/src/tts/providers/microsoft.test.ts b/src/tts/providers/microsoft.test.ts index fa82456be00..f78e09f70e4 100644 --- a/src/tts/providers/microsoft.test.ts +++ b/src/tts/providers/microsoft.test.ts @@ -35,7 +35,10 @@ describe("listMicrosoftVoices", () => { id: "en-US-AvaNeural", name: "Microsoft Ava Online (Natural) - English (United States)", category: "General", - description: "en-US · Female · Friendly, Positive", + description: "Friendly, Positive", + locale: "en-US", + gender: "Female", + personalities: ["Friendly", "Positive"], }, ]); expect(globalThis.fetch).toHaveBeenCalledWith( diff --git a/src/tts/providers/microsoft.ts b/src/tts/providers/microsoft.ts index 06958931ad8..fef369740cb 100644 --- a/src/tts/providers/microsoft.ts +++ b/src/tts/providers/microsoft.ts @@ -39,13 +39,8 @@ function buildMicrosoftVoiceHeaders(): Record { } function formatMicrosoftVoiceDescription(entry: MicrosoftVoiceListEntry): string | undefined { - const parts = [entry.Locale, entry.Gender]; const personalities = entry.VoiceTag?.VoicePersonalities?.filter(Boolean) ?? []; - if (personalities.length > 0) { - parts.push(personalities.join(", ")); - } - const filtered = parts.filter((part): part is string => Boolean(part?.trim())); - return filtered.length > 0 ? filtered.join(" · ") : undefined; + return personalities.length > 0 ? personalities.join(", ") : undefined; } export async function listMicrosoftVoices(): Promise { @@ -67,6 +62,11 @@ export async function listMicrosoftVoices(): Promise { name: voice.FriendlyName?.trim() || voice.ShortName?.trim() || undefined, category: voice.VoiceTag?.ContentCategories?.find((value) => value.trim().length > 0), description: formatMicrosoftVoiceDescription(voice), + locale: voice.Locale?.trim() || undefined, + gender: voice.Gender?.trim() || undefined, + personalities: voice.VoiceTag?.VoicePersonalities?.filter( + (value): value is string => value.trim().length > 0, + ), })) .filter((voice) => voice.id.length > 0) : []; From 14907d3de0a5406666801a32f9b8ffe3eaa86d5f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:27:29 -0700 Subject: [PATCH 044/128] docs(plugins): note richer voice metadata --- docs/tools/plugin.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 8b4d389cd30..36fe8775186 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -687,6 +687,7 @@ Notes: - Uses core `messages.tts` configuration and provider selection. - Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers. - `listVoices` is optional per provider. Use it for vendor-owned voice pickers or setup flows. +- Voice listings can include richer metadata such as locale, gender, and personality tags for provider-aware pickers. - OpenAI and ElevenLabs support telephony today. Microsoft does not. Plugins can also register speech providers via `api.registerSpeechProvider(...)`. From 3e010e280a5d7cb53eb9f5b288b3dc87934f864e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:42:00 -0700 Subject: [PATCH 045/128] feat(plugins): add media understanding provider registration --- extensions/anthropic/index.ts | 2 + extensions/google/index.ts | 2 + extensions/lobster/src/lobster-tool.test.ts | 1 + extensions/minimax/index.ts | 6 +++ extensions/mistral/index.ts | 2 + extensions/moonshot/index.ts | 2 + extensions/openai/index.ts | 2 + extensions/test-utils/plugin-api.ts | 1 + extensions/zai/index.ts | 2 + src/auto-reply/reply/route-reply.test.ts | 1 + .../channel-setup/plugin-install.test.ts | 1 + src/gateway/server-plugins.test.ts | 1 + src/gateway/test-helpers.mocks.ts | 1 + .../providers/index.test.ts | 31 ++++++++++- src/media-understanding/providers/index.ts | 22 +++++++- src/plugin-sdk/core.ts | 1 + src/plugin-sdk/index.ts | 1 + src/plugins/contracts/loader.contract.test.ts | 1 + .../contracts/registry.contract.test.ts | 30 +++++++++++ src/plugins/contracts/registry.ts | 44 ++++++++++++++-- src/plugins/hooks.test-helpers.ts | 12 ++++- src/plugins/loader.ts | 1 + src/plugins/registry.ts | 51 +++++++++++++++++++ src/plugins/types.ts | 4 ++ src/test-utils/channel-plugins.ts | 1 + src/test-utils/plugin-registration.ts | 7 +++ 26 files changed, 222 insertions(+), 8 deletions(-) diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index a2491dfbd87..aad11b99a5b 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -23,6 +23,7 @@ import { import { buildTokenProfileId, validateAnthropicSetupToken } from "../../src/commands/auth-token.js"; import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; import { fetchClaudeUsage } from "../../src/infra/provider-usage.fetch.js"; +import { anthropicProvider } from "../../src/media-understanding/providers/anthropic/index.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import type { ProviderAuthResult } from "../../src/plugins/types.js"; import { normalizeSecretInput } from "../../src/utils/normalize-secret-input.js"; @@ -394,6 +395,7 @@ const anthropicPlugin = { profileId: ctx.profileId, }), }); + api.registerMediaUnderstandingProvider(anthropicProvider); }, }; diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 59d417e9349..177de77e49d 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -7,6 +7,7 @@ import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault, } from "../../src/commands/google-gemini-model-default.js"; +import { googleProvider } from "../../src/media-understanding/providers/google/index.js"; 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"; @@ -51,6 +52,7 @@ const googlePlugin = { isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), }); registerGoogleGeminiCliProvider(api); + api.registerMediaUnderstandingProvider(googleProvider); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ id: "gemini", diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 0ed5c0eda97..cba95624f07 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -45,6 +45,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerService() {}, registerProvider() {}, registerSpeechProvider() {}, + registerMediaUnderstandingProvider() {}, registerWebSearchProvider() {}, registerInteractiveHandler() {}, registerHook() {}, diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 9330e9c4651..8325f6bb078 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -9,6 +9,10 @@ import { import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; import { MINIMAX_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js"; import { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js"; +import { + minimaxPortalProvider, + minimaxProvider, +} from "../../src/media-understanding/providers/minimax/index.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; import { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js"; @@ -270,6 +274,8 @@ const minimaxPlugin = { ], isModernModelRef: ({ modelId }) => isModernMiniMaxModel(modelId), }); + api.registerMediaUnderstandingProvider(minimaxProvider); + api.registerMediaUnderstandingProvider(minimaxPortalProvider); }, }; diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index 10211480a29..7e252281555 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -1,4 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { mistralProvider } from "../../src/media-understanding/providers/mistral/index.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "./onboard.js"; @@ -50,6 +51,7 @@ const mistralPlugin = { ], }, }); + api.registerMediaUnderstandingProvider(mistralProvider); }, }; diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 09605ccff85..5cf18d96d8b 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -7,6 +7,7 @@ import { getScopedCredentialValue, setScopedCredentialValue, } from "../../src/agents/tools/web-search-plugin-factory.js"; +import { moonshotProvider } from "../../src/media-understanding/providers/moonshot/index.js"; 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"; @@ -99,6 +100,7 @@ const moonshotPlugin = { return createMoonshotThinkingWrapper(ctx.streamFn, thinkingType); }, }); + api.registerMediaUnderstandingProvider(moonshotProvider); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ id: "kimi", diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index cd528f72211..2fd57473693 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 { openaiProvider } from "../../src/media-understanding/providers/openai/index.js"; import { buildOpenAISpeechProvider } from "../../src/tts/providers/openai.js"; import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; import { buildOpenAIProvider } from "./openai-provider.js"; @@ -12,6 +13,7 @@ const openAIPlugin = { api.registerProvider(buildOpenAIProvider()); api.registerProvider(buildOpenAICodexProviderPlugin()); api.registerSpeechProvider(buildOpenAISpeechProvider()); + api.registerMediaUnderstandingProvider(openaiProvider); }, }; diff --git a/extensions/test-utils/plugin-api.ts b/extensions/test-utils/plugin-api.ts index 281e151aeb7..82fe818fdec 100644 --- a/extensions/test-utils/plugin-api.ts +++ b/extensions/test-utils/plugin-api.ts @@ -16,6 +16,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi registerService() {}, registerProvider() {}, registerSpeechProvider() {}, + registerMediaUnderstandingProvider() {}, registerWebSearchProvider() {}, registerInteractiveHandler() {}, registerCommand() {}, diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index aee000ec412..f38058dd9e9 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -24,6 +24,7 @@ import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; import type { SecretInput } from "../../src/config/types.secrets.js"; import { resolveRequiredHomeDir } from "../../src/infra/home-dir.js"; import { fetchZaiUsage } from "../../src/infra/provider-usage.fetch.js"; +import { zaiProvider } from "../../src/media-understanding/providers/zai/index.js"; import { normalizeOptionalSecretInput } from "../../src/utils/normalize-secret-input.js"; import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js"; import { applyZaiConfig, applyZaiProviderConfig, ZAI_DEFAULT_MODEL_REF } from "./onboard.js"; @@ -334,6 +335,7 @@ const zaiPlugin = { fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), isCacheTtlEligible: () => true, }); + api.registerMediaUnderstandingProvider(zaiProvider); }, }; diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 5bf5f5c2cec..4c5dd7be889 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -92,6 +92,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => })), providers: [], speechProviders: [], + mediaUnderstandingProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index 5ad6399fa4a..96ca60e2197 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -338,6 +338,7 @@ describe("ensureChannelSetupPluginInstalled", () => { channelIds: [], providerIds: [], speechProviderIds: [], + mediaUnderstandingProviderIds: [], webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 58f5c9da4eb..184cb706762 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -30,6 +30,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ commands: [], providers: [], speechProviders: [], + mediaUnderstandingProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index e05fcc85320..3617bc896bd 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -147,6 +147,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ channelSetups: [], providers: [], speechProviders: [], + mediaUnderstandingProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/media-understanding/providers/index.test.ts b/src/media-understanding/providers/index.test.ts index 9294d44acd5..3441b3a9a25 100644 --- a/src/media-understanding/providers/index.test.ts +++ b/src/media-understanding/providers/index.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { buildMediaUnderstandingRegistry, getMediaUnderstandingProvider } from "./index.js"; describe("media-understanding provider registry", () => { + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + it("registers the Mistral provider", () => { const registry = buildMediaUnderstandingRegistry(); const provider = getMediaUnderstandingProvider("mistral", registry); @@ -32,4 +38,27 @@ describe("media-understanding provider registry", () => { expect(provider?.id).toBe("minimax-portal"); expect(provider?.capabilities).toEqual(["image"]); }); + + it("merges plugin-registered media providers into the active registry", async () => { + const pluginRegistry = createEmptyPluginRegistry(); + pluginRegistry.mediaUnderstandingProviders.push({ + pluginId: "google", + pluginName: "Google Plugin", + source: "test", + provider: { + id: "google", + capabilities: ["image", "audio", "video"], + describeImage: async () => ({ text: "plugin image" }), + transcribeAudio: async () => ({ text: "plugin audio" }), + describeVideo: async () => ({ text: "plugin video" }), + }, + }); + setActivePluginRegistry(pluginRegistry); + + const registry = buildMediaUnderstandingRegistry(); + const provider = getMediaUnderstandingProvider("gemini", registry); + + expect(provider?.id).toBe("google"); + expect(await provider?.describeVideo?.({} as never)).toEqual({ text: "plugin video" }); + }); }); diff --git a/src/media-understanding/providers/index.ts b/src/media-understanding/providers/index.ts index 0ceaa78fd80..6c2e484dbe5 100644 --- a/src/media-understanding/providers/index.ts +++ b/src/media-understanding/providers/index.ts @@ -1,4 +1,5 @@ import { normalizeProviderId } from "../../agents/model-selection.js"; +import { getActivePluginRegistry } from "../../plugins/runtime.js"; import type { MediaUnderstandingProvider } from "../types.js"; import { anthropicProvider } from "./anthropic/index.js"; import { deepgramProvider } from "./deepgram/index.js"; @@ -23,6 +24,22 @@ const PROVIDERS: MediaUnderstandingProvider[] = [ deepgramProvider, ]; +function mergeProviderIntoRegistry( + registry: Map, + provider: MediaUnderstandingProvider, +) { + const normalizedKey = normalizeMediaProviderId(provider.id); + const existing = registry.get(normalizedKey); + const merged = existing + ? { + ...existing, + ...provider, + capabilities: provider.capabilities ?? existing.capabilities, + } + : provider; + registry.set(normalizedKey, merged); +} + export function normalizeMediaProviderId(id: string): string { const normalized = normalizeProviderId(id); if (normalized === "gemini") { @@ -36,7 +53,10 @@ export function buildMediaUnderstandingRegistry( ): Map { const registry = new Map(); for (const provider of PROVIDERS) { - registry.set(normalizeMediaProviderId(provider.id), provider); + mergeProviderIntoRegistry(registry, provider); + } + for (const entry of getActivePluginRegistry()?.mediaUnderstandingProviders ?? []) { + mergeProviderIntoRegistry(registry, entry.provider); } if (overrides) { for (const [key, provider] of Object.entries(overrides)) { diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 00621521067..13b075e3352 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -1,5 +1,6 @@ export type { AnyAgentTool, + MediaUnderstandingProviderPlugin, OpenClawPluginConfigSchema, ProviderDiscoveryContext, ProviderCatalogContext, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 1e78ee1c7e2..c5ba9d90541 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -108,6 +108,7 @@ export { ACP_ERROR_CODES, AcpRuntimeError } from "../acp/runtime/errors.js"; export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js"; export type { AnyAgentTool, + MediaUnderstandingProviderPlugin, OpenClawPluginConfigSchema, OpenClawPluginApi, OpenClawPluginService, diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index a42c24712ec..874a94a0b5e 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -19,6 +19,7 @@ describe("plugin loader contract", () => { loadOpenClawPluginsMock.mockReset(); loadOpenClawPluginsMock.mockReturnValue({ providers: [], + mediaUnderstandingProviders: [], webSearchProviders: [], }); }); diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 48da6c3d9a1..06430449808 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + mediaUnderstandingProviderContractRegistry, pluginRegistrationContractRegistry, providerContractRegistry, speechProviderContractRegistry, @@ -35,6 +36,13 @@ function findSpeechProviderForPlugin(pluginId: string) { return entry.provider; } +function findMediaUnderstandingProviderIdsForPlugin(pluginId: string) { + return mediaUnderstandingProviderContractRegistry + .filter((entry) => entry.pluginId === pluginId) + .map((entry) => entry.provider.id) + .toSorted((left, right) => left.localeCompare(right)); +} + function findRegistrationForPlugin(pluginId: string) { const entry = pluginRegistrationContractRegistry.find( (candidate) => candidate.pluginId === pluginId, @@ -61,6 +69,11 @@ describe("plugin contract registry", () => { expect(ids).toEqual([...new Set(ids)]); }); + it("does not duplicate bundled media provider ids", () => { + const ids = mediaUnderstandingProviderContractRegistry.map((entry) => entry.provider.id); + expect(ids).toEqual([...new Set(ids)]); + }); + it("keeps multi-provider plugin ownership explicit", () => { expect(findProviderIdsForPlugin("google")).toEqual(["google", "google-gemini-cli"]); expect(findProviderIdsForPlugin("minimax")).toEqual(["minimax", "minimax-portal"]); @@ -82,10 +95,24 @@ describe("plugin contract registry", () => { expect(findSpeechProviderIdsForPlugin("openai")).toEqual(["openai"]); }); + it("keeps bundled media-understanding ownership explicit", () => { + expect(findMediaUnderstandingProviderIdsForPlugin("anthropic")).toEqual(["anthropic"]); + expect(findMediaUnderstandingProviderIdsForPlugin("google")).toEqual(["google"]); + expect(findMediaUnderstandingProviderIdsForPlugin("minimax")).toEqual([ + "minimax", + "minimax-portal", + ]); + expect(findMediaUnderstandingProviderIdsForPlugin("mistral")).toEqual(["mistral"]); + expect(findMediaUnderstandingProviderIdsForPlugin("moonshot")).toEqual(["moonshot"]); + expect(findMediaUnderstandingProviderIdsForPlugin("openai")).toEqual(["openai"]); + expect(findMediaUnderstandingProviderIdsForPlugin("zai")).toEqual(["zai"]); + }); + it("keeps bundled provider and web search tool ownership explicit", () => { expect(findRegistrationForPlugin("firecrawl")).toMatchObject({ providerIds: [], speechProviderIds: [], + mediaUnderstandingProviderIds: [], webSearchProviderIds: ["firecrawl"], toolNames: ["firecrawl_search", "firecrawl_scrape"], }); @@ -95,14 +122,17 @@ describe("plugin contract registry", () => { expect(findRegistrationForPlugin("openai")).toMatchObject({ providerIds: ["openai", "openai-codex"], speechProviderIds: ["openai"], + mediaUnderstandingProviderIds: ["openai"], }); expect(findRegistrationForPlugin("elevenlabs")).toMatchObject({ providerIds: [], speechProviderIds: ["elevenlabs"], + mediaUnderstandingProviderIds: [], }); expect(findRegistrationForPlugin("microsoft")).toMatchObject({ providerIds: [], speechProviderIds: ["microsoft"], + mediaUnderstandingProviderIds: [], }); }); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 1dc997d7b2e..14dbb17262c 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -35,7 +35,12 @@ import xaiPlugin from "../../../extensions/xai/index.js"; import xiaomiPlugin from "../../../extensions/xiaomi/index.js"; import zaiPlugin from "../../../extensions/zai/index.js"; import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; -import type { ProviderPlugin, SpeechProviderPlugin, WebSearchProviderPlugin } from "../types.js"; +import type { + MediaUnderstandingProviderPlugin, + ProviderPlugin, + SpeechProviderPlugin, + WebSearchProviderPlugin, +} from "../types.js"; type RegistrablePlugin = { id: string; @@ -58,10 +63,16 @@ type SpeechProviderContractEntry = { provider: SpeechProviderPlugin; }; +type MediaUnderstandingProviderContractEntry = { + pluginId: string; + provider: MediaUnderstandingProviderPlugin; +}; + type PluginRegistrationContractEntry = { pluginId: string; providerIds: string[]; speechProviderIds: string[]; + mediaUnderstandingProviderIds: string[]; webSearchProviderIds: string[]; toolNames: string[]; }; @@ -111,6 +122,16 @@ const bundledWebSearchPlugins: Array { + const captured = captureRegistrations(plugin); + return captured.mediaUnderstandingProviders.map((provider) => ({ + pluginId: plugin.id, + provider, + })); + }); + const bundledPluginRegistrationList = [ ...new Map( - [...bundledProviderPlugins, ...bundledSpeechPlugins, ...bundledWebSearchPlugins].map( - (plugin) => [plugin.id, plugin], - ), + [ + ...bundledProviderPlugins, + ...bundledSpeechPlugins, + ...bundledMediaUnderstandingPlugins, + ...bundledWebSearchPlugins, + ].map((plugin) => [plugin.id, plugin]), ).values(), ]; @@ -161,6 +194,9 @@ export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry pluginId: plugin.id, providerIds: captured.providers.map((provider) => provider.id), speechProviderIds: captured.speechProviders.map((provider) => provider.id), + mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map( + (provider) => provider.id, + ), webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id), toolNames: captured.tools.map((tool) => tool.name), }; diff --git a/src/plugins/hooks.test-helpers.ts b/src/plugins/hooks.test-helpers.ts index 7954257e714..ea01163d4b0 100644 --- a/src/plugins/hooks.test-helpers.ts +++ b/src/plugins/hooks.test-helpers.ts @@ -17,6 +17,9 @@ export function createMockPluginRegistry( hookNames: [], channelIds: [], providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], services: [], @@ -35,13 +38,18 @@ export function createMockPluginRegistry( source: "test", })), tools: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + webSearchProviders: [], httpRoutes: [], - channelRegistrations: [], gatewayHandlers: {}, cliRegistrars: [], services: [], - providers: [], commands: [], + diagnostics: [], } as unknown as PluginRegistry; } diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index a2e05fc06b9..873fff6b9bf 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -495,6 +495,7 @@ function createPluginRecord(params: { channelIds: [], providerIds: [], speechProviderIds: [], + mediaUnderstandingProviderIds: [], webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 231e6f267aa..bad444289ac 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -31,6 +31,7 @@ import type { OpenClawPluginHttpRouteHandler, OpenClawPluginHttpRouteParams, OpenClawPluginHookOptions, + MediaUnderstandingProviderPlugin, ProviderPlugin, OpenClawPluginService, OpenClawPluginToolContext, @@ -119,6 +120,14 @@ export type PluginSpeechProviderRegistration = { rootDir?: string; }; +export type PluginMediaUnderstandingProviderRegistration = { + pluginId: string; + pluginName?: string; + provider: MediaUnderstandingProviderPlugin; + source: string; + rootDir?: string; +}; + export type PluginHookRegistration = { pluginId: string; entry: HookEntry; @@ -164,6 +173,7 @@ export type PluginRecord = { channelIds: string[]; providerIds: string[]; speechProviderIds: string[]; + mediaUnderstandingProviderIds: string[]; webSearchProviderIds: string[]; gatewayMethods: string[]; cliCommands: string[]; @@ -185,6 +195,7 @@ export type PluginRegistry = { channelSetups: PluginChannelSetupRegistration[]; providers: PluginProviderRegistration[]; speechProviders: PluginSpeechProviderRegistration[]; + mediaUnderstandingProviders: PluginMediaUnderstandingProviderRegistration[]; webSearchProviders: PluginWebSearchProviderRegistration[]; gatewayHandlers: GatewayRequestHandlers; httpRoutes: PluginHttpRouteRegistration[]; @@ -231,6 +242,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { channelSetups: [], providers: [], speechProviders: [], + mediaUnderstandingProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], @@ -593,6 +605,40 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const registerMediaUnderstandingProvider = ( + record: PluginRecord, + provider: MediaUnderstandingProviderPlugin, + ) => { + const id = provider.id.trim(); + if (!id) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "media provider registration missing id", + }); + return; + } + const existing = registry.mediaUnderstandingProviders.find((entry) => entry.provider.id === id); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `media provider already registered: ${id} (${existing.pluginId})`, + }); + return; + } + record.mediaUnderstandingProviderIds.push(id); + registry.mediaUnderstandingProviders.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) { @@ -836,6 +882,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registrationMode === "full" ? (provider) => registerSpeechProvider(record, provider) : () => {}, + registerMediaUnderstandingProvider: + registrationMode === "full" + ? (provider) => registerMediaUnderstandingProvider(record, provider) + : () => {}, registerWebSearchProvider: registrationMode === "full" ? (provider) => registerWebSearchProvider(record, provider) @@ -910,6 +960,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerChannel, registerProvider, registerSpeechProvider, + registerMediaUnderstandingProvider, registerWebSearchProvider, registerGatewayMethod, registerCli, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 0add5cdcf42..23e761940df 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -25,6 +25,7 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import type { InternalHookHandler } from "../hooks/internal-hooks.js"; import type { HookEntry } from "../hooks/types.js"; import type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js"; +import type { MediaUnderstandingProvider } from "../media-understanding/types.js"; import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; import type { @@ -881,6 +882,8 @@ export type PluginSpeechProviderEntry = SpeechProviderPlugin & { pluginId: string; }; +export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider; + export type OpenClawPluginGatewayMethod = { method: string; handler: GatewayRequestHandler; @@ -1240,6 +1243,7 @@ export type OpenClawPluginApi = { registerService: (service: OpenClawPluginService) => void; registerProvider: (provider: ProviderPlugin) => void; registerSpeechProvider: (provider: SpeechProviderPlugin) => void; + registerMediaUnderstandingProvider: (provider: MediaUnderstandingProviderPlugin) => void; registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void; registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void; /** diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 588c1ca7db6..1283ac9f506 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -27,6 +27,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl })), providers: [], speechProviders: [], + mediaUnderstandingProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/test-utils/plugin-registration.ts b/src/test-utils/plugin-registration.ts index 6231dedf17b..de8e5422ccf 100644 --- a/src/test-utils/plugin-registration.ts +++ b/src/test-utils/plugin-registration.ts @@ -1,5 +1,6 @@ import type { AnyAgentTool, + MediaUnderstandingProviderPlugin, OpenClawPluginApi, ProviderPlugin, SpeechProviderPlugin, @@ -10,6 +11,7 @@ export type CapturedPluginRegistration = { api: OpenClawPluginApi; providers: ProviderPlugin[]; speechProviders: SpeechProviderPlugin[]; + mediaUnderstandingProviders: MediaUnderstandingProviderPlugin[]; webSearchProviders: WebSearchProviderPlugin[]; tools: AnyAgentTool[]; }; @@ -17,12 +19,14 @@ export type CapturedPluginRegistration = { export function createCapturedPluginRegistration(): CapturedPluginRegistration { const providers: ProviderPlugin[] = []; const speechProviders: SpeechProviderPlugin[] = []; + const mediaUnderstandingProviders: MediaUnderstandingProviderPlugin[] = []; const webSearchProviders: WebSearchProviderPlugin[] = []; const tools: AnyAgentTool[] = []; return { providers, speechProviders, + mediaUnderstandingProviders, webSearchProviders, tools, api: { @@ -32,6 +36,9 @@ export function createCapturedPluginRegistration(): CapturedPluginRegistration { registerSpeechProvider(provider: SpeechProviderPlugin) { speechProviders.push(provider); }, + registerMediaUnderstandingProvider(provider: MediaUnderstandingProviderPlugin) { + mediaUnderstandingProviders.push(provider); + }, registerWebSearchProvider(provider: WebSearchProviderPlugin) { webSearchProviders.push(provider); }, From 3566e88c08957bc67469e6848e9b7602d879e963 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:42:08 -0700 Subject: [PATCH 046/128] docs(plugins): document media capability ownership --- docs/nodes/media-understanding.md | 17 +++++++---- docs/tools/plugin.md | 49 +++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/docs/nodes/media-understanding.md b/docs/nodes/media-understanding.md index dae748633bd..ab3701387be 100644 --- a/docs/nodes/media-understanding.md +++ b/docs/nodes/media-understanding.md @@ -10,6 +10,10 @@ title: "Media Understanding" OpenClaw can **summarize inbound media** (image/audio/video) before the reply pipeline runs. It auto‑detects when local tools or provider keys are available, and can be disabled or customized. If understanding is off, models still receive the original files/URLs as usual. +Vendor-specific media behavior is registered by vendor plugins, while OpenClaw +core owns the shared `tools.media` config, fallback order, and reply-pipeline +integration. + ## Goals - Optional: pre‑digest inbound media into short text for faster routing + better command parsing. @@ -184,7 +188,10 @@ If you set `capabilities`, the entry only runs for those media types. For shared lists, OpenClaw can infer defaults: - `openai`, `anthropic`, `minimax`: **image** +- `moonshot`: **image + video** - `google` (Gemini API): **image + audio + video** +- `mistral`: **audio** +- `zai`: **image** - `groq`: **audio** - `deepgram`: **audio** @@ -193,11 +200,11 @@ If you omit `capabilities`, the entry is eligible for the list it appears in. ## Provider support matrix (OpenClaw integrations) -| Capability | Provider integration | Notes | -| ---------- | ------------------------------------------------ | --------------------------------------------------------- | -| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. | -| Audio | OpenAI, Groq, Deepgram, Google, Mistral | Provider transcription (Whisper/Deepgram/Gemini/Voxtral). | -| Video | Google (Gemini API) | Provider video understanding. | +| Capability | Provider integration | Notes | +| ---------- | -------------------------------------------------- | ----------------------------------------------------------------------- | +| Image | OpenAI, Anthropic, Google, MiniMax, Moonshot, Z.AI | Vendor plugins register image support against core media understanding. | +| Audio | OpenAI, Groq, Deepgram, Google, Mistral | Provider transcription (Whisper/Deepgram/Gemini/Voxtral). | +| Video | Google, Moonshot | Provider video understanding via vendor plugins. | ## Model selection guidance diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 36fe8775186..7a92cda65f0 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -113,9 +113,11 @@ That means: Examples: - the bundled `openai` plugin owns OpenAI model-provider behavior and OpenAI - speech behavior + speech + media-understanding behavior - the bundled `elevenlabs` plugin owns ElevenLabs speech behavior - the bundled `microsoft` plugin owns Microsoft speech behavior +- the bundled `google`, `minimax`, `mistral`, `moonshot`, and `zai` plugins own + their media-understanding backends - 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 @@ -167,17 +169,23 @@ For example, TTS follows this shape: That same pattern should be preferred for future capabilities. -### Capability example: video +### Capability example: video understanding -If OpenClaw adds video, prefer this order: +OpenClaw already treats image/audio/video understanding as one shared +capability. The same ownership model applies there: -1. define a core video capability -2. decide the shared contract: input media shape, provider result shape, cache/fallback behavior, and runtime helpers -3. let vendor plugins such as `openai` or a future video vendor register video implementations -4. let channels or feature plugins consume `api.runtime.video` instead of wiring directly to a provider plugin +1. core defines the media-understanding contract +2. vendor plugins register `describeImage`, `transcribeAudio`, and + `describeVideo` as applicable +3. channels and feature plugins consume the shared core behavior instead of + wiring directly to vendor code -This avoids baking one provider's video assumptions into core. The plugin owns -the vendor surface; core owns the capability contract. +That avoids baking one provider's video assumptions into core. The plugin owns +the vendor surface; core owns the capability contract and fallback behavior. + +If OpenClaw adds a new domain later, such as video generation, use the same +sequence again: define the core capability first, then let vendor plugins +register implementations against it. ## Compatible bundles @@ -717,6 +725,28 @@ Notes: text, speech, image, and future media providers as OpenClaw adds those capability contracts. +For image/audio/video understanding, plugins register one typed +media-understanding provider instead of a generic key/value bag: + +```ts +api.registerMediaUnderstandingProvider({ + id: "google", + capabilities: ["image", "audio", "video"], + describeImage: async (req) => ({ text: "..." }), + transcribeAudio: async (req) => ({ text: "..." }), + describeVideo: async (req) => ({ text: "..." }), +}); +``` + +Notes: + +- Keep orchestration, fallback, config, and channel wiring in core. +- Keep vendor behavior in the provider plugin. +- Additive expansion should stay typed: new optional methods, new optional + result fields, new optional capabilities. +- If OpenClaw adds a new capability such as video generation later, define the + core capability contract first, then let vendor plugins register against it. + For STT/transcription, plugins can call: ```ts @@ -1294,6 +1324,7 @@ Plugins export either: - `registerChannel` - `registerProvider` - `registerSpeechProvider` +- `registerMediaUnderstandingProvider` - `registerWebSearchProvider` - `registerHttpRoute` - `registerCommand` From c64f6adc83a53e981f0b449d09d71464aa15e75f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:49:31 -0700 Subject: [PATCH 047/128] refactor: finish provider auth extraction and canonicalize kimi --- extensions/cloudflare-ai-gateway/index.ts | 2 +- extensions/kimi-coding/index.ts | 71 ++- extensions/kimi-coding/onboard.ts | 36 +- extensions/kimi-coding/openclaw.plugin.json | 17 +- extensions/kimi-coding/package.json | 2 +- extensions/kimi-coding/provider-catalog.ts | 26 +- extensions/minimax/model-definitions.ts | 64 +++ extensions/minimax/onboard.ts | 6 +- extensions/mistral/model-definitions.ts | 25 ++ extensions/mistral/onboard.ts | 7 +- extensions/modelstudio/model-definitions.ts | 102 +++++ extensions/modelstudio/onboard.ts | 4 +- extensions/moonshot/index.ts | 16 +- extensions/moonshot/openclaw.plugin.json | 8 +- extensions/xai/model-definitions.ts | 25 ++ extensions/xai/onboard.ts | 7 +- extensions/zai/index.ts | 2 +- extensions/zai/model-definitions.ts | 60 +++ extensions/zai/onboard.ts | 7 +- src/agents/model-selection.test.ts | 3 +- src/agents/models-config.merge.test.ts | 8 +- ...odels-config.providers.kimi-coding.test.ts | 27 +- .../pi-embedded-runner-extraparams.test.ts | 8 +- src/agents/pi-embedded-runner/model.test.ts | 15 +- src/agents/pi-embedded-runner/run/attempt.ts | 4 +- src/agents/provider-capabilities.test.ts | 8 +- src/agents/provider-id.ts | 7 +- src/agents/transcript-policy.test.ts | 8 +- src/auto-reply/reply/model-selection.test.ts | 12 +- src/commands/auth-choice.test.ts | 12 +- src/commands/auth-credentials.ts | 189 ++++++++ src/commands/auth-profile-config.ts | 5 +- src/commands/onboard-auth.config-shared.ts | 10 +- src/commands/onboard-auth.credentials.ts | 218 +--------- src/commands/onboard-auth.models.ts | 408 +++++------------- src/commands/onboard-auth.ts | 51 ++- src/commands/zai-endpoint-detect.ts | 4 +- src/cron/isolated-agent/session.test.ts | 4 +- src/plugins/bundled-dir.ts | 6 +- src/plugins/config-state.ts | 3 +- src/plugins/provider-api-key-auth.runtime.ts | 2 +- 41 files changed, 837 insertions(+), 662 deletions(-) create mode 100644 extensions/minimax/model-definitions.ts create mode 100644 extensions/mistral/model-definitions.ts create mode 100644 extensions/modelstudio/model-definitions.ts create mode 100644 extensions/xai/model-definitions.ts create mode 100644 extensions/zai/model-definitions.ts create mode 100644 src/commands/auth-credentials.ts diff --git a/extensions/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts index 782cb43786d..aa584af8208 100644 --- a/extensions/cloudflare-ai-gateway/index.ts +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -11,7 +11,7 @@ import { validateApiKeyInput, } from "../../src/commands/auth-choice.api-key.js"; import { ensureApiKeyFromOptionEnvOrPrompt } from "../../src/commands/auth-choice.apply-helpers.js"; -import { buildApiKeyCredential } from "../../src/commands/onboard-auth.credentials.js"; +import { buildApiKeyCredential } from "../../src/commands/auth-credentials.js"; import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; import type { SecretInput } from "../../src/config/types.secrets.js"; import { coerceSecretRef } from "../../src/config/types.secrets.js"; diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index a109cc1075a..709e5a8de4c 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -1,47 +1,69 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/core"; +import { findNormalizedProviderValue, normalizeProviderId } from "../../src/agents/provider-id.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { isRecord } from "../../src/utils.js"; -import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "./onboard.js"; -import { buildKimiCodingProvider } from "./provider-catalog.js"; +import { applyKimiCodeConfig, KIMI_DEFAULT_MODEL_REF } from "./onboard.js"; +import { + buildKimiProvider, + KIMI_DEFAULT_MODEL_ID, + KIMI_LEGACY_MODEL_ID, + KIMI_UPSTREAM_MODEL_ID, +} from "./provider-catalog.js"; -const PROVIDER_ID = "kimi-coding"; +const PROVIDER_ID = "kimi"; +const KIMI_TRANSPORT_MODEL_IDS = new Set([KIMI_DEFAULT_MODEL_ID, KIMI_LEGACY_MODEL_ID]); + +function normalizeKimiTransportModel(model: ProviderRuntimeModel): ProviderRuntimeModel { + if (!KIMI_TRANSPORT_MODEL_IDS.has(model.id)) { + return model; + } + return { + ...model, + id: KIMI_UPSTREAM_MODEL_ID, + name: "Kimi Code", + }; +} const kimiCodingPlugin = { id: PROVIDER_ID, - name: "Kimi Provider", - description: "Bundled Kimi provider plugin", + name: "Kimi Code Provider", + description: "Bundled Kimi Code provider plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerProvider({ id: PROVIDER_ID, - label: "Kimi", - aliases: ["kimi", "kimi-code"], + label: "Kimi Code", + aliases: ["kimi-code", "kimi-coding"], docsPath: "/providers/moonshot", envVars: ["KIMI_API_KEY", "KIMICODE_API_KEY"], auth: [ createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key", - label: "Kimi API key (subscription)", - hint: "Kimi K2.5 + Kimi", + label: "Kimi Code API key", + hint: "Dedicated coding endpoint", optionKey: "kimiCodeApiKey", flagName: "--kimi-code-api-key", envVar: "KIMI_API_KEY", - promptMessage: "Enter Kimi API key", - defaultModel: KIMI_CODING_MODEL_REF, + promptMessage: "Enter Kimi Code API key", + defaultModel: KIMI_DEFAULT_MODEL_REF, expectedProviders: ["kimi", "kimi-code", "kimi-coding"], applyConfig: (cfg) => applyKimiCodeConfig(cfg), noteMessage: [ - "Kimi uses a dedicated coding endpoint and API key.", + "Kimi Code uses a dedicated coding endpoint and API key.", "Get your API key at: https://www.kimi.com/code/en", ].join("\n"), - noteTitle: "Kimi", + noteTitle: "Kimi Code", wizard: { choiceId: "kimi-code-api-key", - choiceLabel: "Kimi API key (subscription)", - groupId: "moonshot", - groupLabel: "Moonshot AI (Kimi K2.5)", - groupHint: "Kimi K2.5 + Kimi", + choiceLabel: "Kimi Code API key", + groupId: "kimi-code", + groupLabel: "Kimi Code", + groupHint: "Dedicated coding endpoint", }, }), ], @@ -52,8 +74,11 @@ const kimiCodingPlugin = { if (!apiKey) { return null; } - const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; - const builtInProvider = buildKimiCodingProvider(); + const explicitProvider = findNormalizedProviderValue( + ctx.config.models?.providers, + PROVIDER_ID, + ); + const builtInProvider = buildKimiProvider(); const explicitBaseUrl = typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; const explicitHeaders = isRecord(explicitProvider?.headers) @@ -79,6 +104,12 @@ const kimiCodingPlugin = { capabilities: { preserveAnthropicThinkingSignatures: false, }, + normalizeResolvedModel: (ctx) => { + if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { + return undefined; + } + return normalizeKimiTransportModel(ctx.model); + }, }); }, }; diff --git a/extensions/kimi-coding/onboard.ts b/extensions/kimi-coding/onboard.ts index 5b1102b8ec1..07feea91327 100644 --- a/extensions/kimi-coding/onboard.ts +++ b/extensions/kimi-coding/onboard.ts @@ -1,38 +1,44 @@ import { applyAgentDefaultModelPrimary, - applyProviderConfigWithDefaultModel, + applyProviderConfigWithModelCatalog, } from "../../src/commands/onboard-auth.config-shared.js"; import type { OpenClawConfig } from "../../src/config/config.js"; import { buildKimiCodingProvider, - KIMI_CODING_BASE_URL, - KIMI_CODING_DEFAULT_MODEL_ID, + KIMI_BASE_URL, + KIMI_DEFAULT_MODEL_ID, + KIMI_LEGACY_MODEL_ID, } from "./provider-catalog.js"; -export const KIMI_CODING_MODEL_REF = `kimi-coding/${KIMI_CODING_DEFAULT_MODEL_ID}`; +export const KIMI_DEFAULT_MODEL_REF = `kimi/${KIMI_DEFAULT_MODEL_ID}`; +export const KIMI_LEGACY_MODEL_REF = `kimi/${KIMI_LEGACY_MODEL_ID}`; +export const KIMI_CODING_MODEL_REF = KIMI_DEFAULT_MODEL_REF; export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; - models[KIMI_CODING_MODEL_REF] = { - ...models[KIMI_CODING_MODEL_REF], - alias: models[KIMI_CODING_MODEL_REF]?.alias ?? "Kimi", + models[KIMI_DEFAULT_MODEL_REF] = { + ...models[KIMI_DEFAULT_MODEL_REF], + alias: models[KIMI_DEFAULT_MODEL_REF]?.alias ?? "Kimi Code", + }; + models[KIMI_LEGACY_MODEL_REF] = { + ...models[KIMI_LEGACY_MODEL_REF], + alias: models[KIMI_LEGACY_MODEL_REF]?.alias ?? "Kimi Code", }; - const defaultModel = buildKimiCodingProvider().models[0]; - if (!defaultModel) { + const catalog = buildKimiCodingProvider().models ?? []; + if (catalog.length === 0) { return cfg; } - return applyProviderConfigWithDefaultModel(cfg, { + return applyProviderConfigWithModelCatalog(cfg, { agentModels: models, - providerId: "kimi-coding", + providerId: "kimi", api: "anthropic-messages", - baseUrl: KIMI_CODING_BASE_URL, - defaultModel, - defaultModelId: KIMI_CODING_DEFAULT_MODEL_ID, + baseUrl: KIMI_BASE_URL, + catalogModels: catalog, }); } export function applyKimiCodeConfig(cfg: OpenClawConfig): OpenClawConfig { - return applyAgentDefaultModelPrimary(applyKimiCodeProviderConfig(cfg), KIMI_CODING_MODEL_REF); + return applyAgentDefaultModelPrimary(applyKimiCodeProviderConfig(cfg), KIMI_DEFAULT_MODEL_REF); } diff --git a/extensions/kimi-coding/openclaw.plugin.json b/extensions/kimi-coding/openclaw.plugin.json index a9ee5c991ca..9d2ba7f69bb 100644 --- a/extensions/kimi-coding/openclaw.plugin.json +++ b/extensions/kimi-coding/openclaw.plugin.json @@ -1,22 +1,23 @@ { - "id": "kimi-coding", - "providers": ["kimi-coding"], + "id": "kimi", + "providers": ["kimi", "kimi-coding"], "providerAuthEnvVars": { + "kimi": ["KIMI_API_KEY", "KIMICODE_API_KEY"], "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"] }, "providerAuthChoices": [ { - "provider": "kimi-coding", + "provider": "kimi", "method": "api-key", "choiceId": "kimi-code-api-key", - "choiceLabel": "Kimi API key (subscription)", - "groupId": "moonshot", - "groupLabel": "Moonshot AI (Kimi K2.5)", - "groupHint": "Kimi K2.5 + Kimi", + "choiceLabel": "Kimi Code API key", + "groupId": "kimi-code", + "groupLabel": "Kimi Code", + "groupHint": "Dedicated coding endpoint", "optionKey": "kimiCodeApiKey", "cliFlag": "--kimi-code-api-key", "cliOption": "--kimi-code-api-key ", - "cliDescription": "Kimi API key" + "cliDescription": "Kimi Code API key" } ], "configSchema": { diff --git a/extensions/kimi-coding/package.json b/extensions/kimi-coding/package.json index e041999065d..9568afa64b4 100644 --- a/extensions/kimi-coding/package.json +++ b/extensions/kimi-coding/package.json @@ -1,5 +1,5 @@ { - "name": "@openclaw/kimi-coding-provider", + "name": "@openclaw/kimi-provider", "version": "2026.3.14", "private": true, "description": "OpenClaw Kimi provider plugin", diff --git a/extensions/kimi-coding/provider-catalog.ts b/extensions/kimi-coding/provider-catalog.ts index 307fc65f0d1..439c86fdff0 100644 --- a/extensions/kimi-coding/provider-catalog.ts +++ b/extensions/kimi-coding/provider-catalog.ts @@ -1,8 +1,10 @@ import type { ModelProviderConfig } from "../../src/config/types.models.js"; -export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"; +export const KIMI_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"; +export const KIMI_DEFAULT_MODEL_ID = "kimi-code"; +export const KIMI_UPSTREAM_MODEL_ID = "kimi-for-coding"; +export const KIMI_LEGACY_MODEL_ID = "k2p5"; const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144; const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768; const KIMI_CODING_DEFAULT_COST = { @@ -14,15 +16,24 @@ const KIMI_CODING_DEFAULT_COST = { export function buildKimiCodingProvider(): ModelProviderConfig { return { - baseUrl: KIMI_CODING_BASE_URL, + baseUrl: KIMI_BASE_URL, api: "anthropic-messages", headers: { "User-Agent": KIMI_CODING_USER_AGENT, }, models: [ { - id: KIMI_CODING_DEFAULT_MODEL_ID, - name: "Kimi", + id: KIMI_DEFAULT_MODEL_ID, + name: "Kimi Code", + reasoning: true, + input: ["text", "image"], + cost: KIMI_CODING_DEFAULT_COST, + contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW, + maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS, + }, + { + id: KIMI_LEGACY_MODEL_ID, + name: "Kimi Code (legacy model id)", reasoning: true, input: ["text", "image"], cost: KIMI_CODING_DEFAULT_COST, @@ -32,3 +43,8 @@ export function buildKimiCodingProvider(): ModelProviderConfig { ], }; } + +export const KIMI_CODING_BASE_URL = KIMI_BASE_URL; +export const KIMI_CODING_DEFAULT_MODEL_ID = KIMI_DEFAULT_MODEL_ID; +export const KIMI_CODING_LEGACY_MODEL_ID = KIMI_LEGACY_MODEL_ID; +export const buildKimiProvider = buildKimiCodingProvider; diff --git a/extensions/minimax/model-definitions.ts b/extensions/minimax/model-definitions.ts new file mode 100644 index 00000000000..a913a933cf7 --- /dev/null +++ b/extensions/minimax/model-definitions.ts @@ -0,0 +1,64 @@ +import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; + +export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1"; +export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; +export const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic"; +export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.5"; +export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; +export const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000; +export const DEFAULT_MINIMAX_MAX_TOKENS = 8192; + +export const MINIMAX_API_COST = { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0.12, +}; +export const MINIMAX_HOSTED_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; +export const MINIMAX_LM_STUDIO_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const MINIMAX_MODEL_CATALOG = { + "MiniMax-M2.5": { name: "MiniMax M2.5", reasoning: true }, + "MiniMax-M2.5-highspeed": { name: "MiniMax M2.5 Highspeed", reasoning: true }, +} as const; + +type MinimaxCatalogId = keyof typeof MINIMAX_MODEL_CATALOG; + +export function buildMinimaxModelDefinition(params: { + id: string; + name?: string; + reasoning?: boolean; + cost: ModelDefinitionConfig["cost"]; + contextWindow: number; + maxTokens: number; +}): ModelDefinitionConfig { + const catalog = MINIMAX_MODEL_CATALOG[params.id as MinimaxCatalogId]; + return { + id: params.id, + name: params.name ?? catalog?.name ?? `MiniMax ${params.id}`, + reasoning: params.reasoning ?? catalog?.reasoning ?? false, + input: ["text"], + cost: params.cost, + contextWindow: params.contextWindow, + maxTokens: params.maxTokens, + }; +} + +export function buildMinimaxApiModelDefinition(modelId: string): ModelDefinitionConfig { + return buildMinimaxModelDefinition({ + id: modelId, + cost: MINIMAX_API_COST, + contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW, + maxTokens: DEFAULT_MINIMAX_MAX_TOKENS, + }); +} diff --git a/extensions/minimax/onboard.ts b/extensions/minimax/onboard.ts index 5c18a3c44ff..6a2ff47e1f0 100644 --- a/extensions/minimax/onboard.ts +++ b/extensions/minimax/onboard.ts @@ -2,13 +2,13 @@ import { applyAgentDefaultModelPrimary, applyOnboardAuthAgentModelsAndProviders, } from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; import { buildMinimaxApiModelDefinition, MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL, -} from "../../src/commands/onboard-auth.models.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +} from "./model-definitions.js"; type MinimaxApiProviderConfigParams = { providerId: string; diff --git a/extensions/mistral/model-definitions.ts b/extensions/mistral/model-definitions.ts new file mode 100644 index 00000000000..90d3c84c73d --- /dev/null +++ b/extensions/mistral/model-definitions.ts @@ -0,0 +1,25 @@ +import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; + +export const MISTRAL_BASE_URL = "https://api.mistral.ai/v1"; +export const MISTRAL_DEFAULT_MODEL_ID = "mistral-large-latest"; +export const MISTRAL_DEFAULT_MODEL_REF = `mistral/${MISTRAL_DEFAULT_MODEL_ID}`; +export const MISTRAL_DEFAULT_CONTEXT_WINDOW = 262144; +export const MISTRAL_DEFAULT_MAX_TOKENS = 262144; +export const MISTRAL_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export function buildMistralModelDefinition(): ModelDefinitionConfig { + return { + id: MISTRAL_DEFAULT_MODEL_ID, + name: "Mistral Large", + reasoning: false, + input: ["text", "image"], + cost: MISTRAL_DEFAULT_COST, + contextWindow: MISTRAL_DEFAULT_CONTEXT_WINDOW, + maxTokens: MISTRAL_DEFAULT_MAX_TOKENS, + }; +} diff --git a/extensions/mistral/onboard.ts b/extensions/mistral/onboard.ts index 28a6d12ce17..9a60e3f7c72 100644 --- a/extensions/mistral/onboard.ts +++ b/extensions/mistral/onboard.ts @@ -2,14 +2,15 @@ import { applyAgentDefaultModelPrimary, applyProviderConfigWithDefaultModel, } from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; import { buildMistralModelDefinition, MISTRAL_BASE_URL, MISTRAL_DEFAULT_MODEL_ID, -} from "../../src/commands/onboard-auth.models.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; + MISTRAL_DEFAULT_MODEL_REF, +} from "./model-definitions.js"; -export const MISTRAL_DEFAULT_MODEL_REF = `mistral/${MISTRAL_DEFAULT_MODEL_ID}`; +export { MISTRAL_DEFAULT_MODEL_REF }; export function applyMistralProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; diff --git a/extensions/modelstudio/model-definitions.ts b/extensions/modelstudio/model-definitions.ts new file mode 100644 index 00000000000..765e3962329 --- /dev/null +++ b/extensions/modelstudio/model-definitions.ts @@ -0,0 +1,102 @@ +import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; + +export const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; +export const MODELSTUDIO_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; +export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus"; +export const MODELSTUDIO_DEFAULT_MODEL_REF = `modelstudio/${MODELSTUDIO_DEFAULT_MODEL_ID}`; +export const MODELSTUDIO_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const MODELSTUDIO_MODEL_CATALOG = { + "qwen3.5-plus": { + name: "qwen3.5-plus", + reasoning: false, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 65536, + }, + "qwen3-max-2026-01-23": { + name: "qwen3-max-2026-01-23", + reasoning: false, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + }, + "qwen3-coder-next": { + name: "qwen3-coder-next", + reasoning: false, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + }, + "qwen3-coder-plus": { + name: "qwen3-coder-plus", + reasoning: false, + input: ["text"], + contextWindow: 1000000, + maxTokens: 65536, + }, + "MiniMax-M2.5": { + name: "MiniMax-M2.5", + reasoning: false, + input: ["text"], + contextWindow: 1000000, + maxTokens: 65536, + }, + "glm-5": { + name: "glm-5", + reasoning: false, + input: ["text"], + contextWindow: 202752, + maxTokens: 16384, + }, + "glm-4.7": { + name: "glm-4.7", + reasoning: false, + input: ["text"], + contextWindow: 202752, + maxTokens: 16384, + }, + "kimi-k2.5": { + name: "kimi-k2.5", + reasoning: false, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 32768, + }, +} as const; + +type ModelStudioCatalogId = keyof typeof MODELSTUDIO_MODEL_CATALOG; + +export function buildModelStudioModelDefinition(params: { + id: string; + name?: string; + reasoning?: boolean; + input?: string[]; + cost?: ModelDefinitionConfig["cost"]; + contextWindow?: number; + maxTokens?: number; +}): ModelDefinitionConfig { + const catalog = MODELSTUDIO_MODEL_CATALOG[params.id as ModelStudioCatalogId]; + return { + id: params.id, + name: params.name ?? catalog?.name ?? params.id, + reasoning: params.reasoning ?? catalog?.reasoning ?? false, + input: + (params.input as ("text" | "image")[]) ?? + ([...(catalog?.input ?? ["text"])] as ("text" | "image")[]), + cost: params.cost ?? MODELSTUDIO_DEFAULT_COST, + contextWindow: params.contextWindow ?? catalog?.contextWindow ?? 262144, + maxTokens: params.maxTokens ?? catalog?.maxTokens ?? 65536, + }; +} + +export function buildModelStudioDefaultModelDefinition(): ModelDefinitionConfig { + return buildModelStudioModelDefinition({ + id: MODELSTUDIO_DEFAULT_MODEL_ID, + }); +} diff --git a/extensions/modelstudio/onboard.ts b/extensions/modelstudio/onboard.ts index e8d7d5bbacb..9a8760b8550 100644 --- a/extensions/modelstudio/onboard.ts +++ b/extensions/modelstudio/onboard.ts @@ -2,12 +2,12 @@ import { applyAgentDefaultModelPrimary, applyProviderConfigWithModelCatalog, } from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; import { MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL, -} from "../../src/commands/onboard-auth.models.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; +} from "./model-definitions.js"; import { buildModelStudioProvider } from "./provider-catalog.js"; export { MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL }; diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 5cf18d96d8b..c7183c3d7ce 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -35,8 +35,8 @@ const moonshotPlugin = { createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key", - label: "Kimi API key (.ai)", - hint: "Kimi K2.5 + Kimi", + label: "Moonshot API key (.ai)", + hint: "Kimi K2.5", optionKey: "moonshotApiKey", flagName: "--moonshot-api-key", envVar: "MOONSHOT_API_KEY", @@ -46,17 +46,17 @@ const moonshotPlugin = { applyConfig: (cfg) => applyMoonshotConfig(cfg), wizard: { choiceId: "moonshot-api-key", - choiceLabel: "Kimi API key (.ai)", + choiceLabel: "Moonshot API key (.ai)", groupId: "moonshot", groupLabel: "Moonshot AI (Kimi K2.5)", - groupHint: "Kimi K2.5 + Kimi", + groupHint: "Kimi K2.5", }, }), createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key-cn", - label: "Kimi API key (.cn)", - hint: "Kimi K2.5 + Kimi", + label: "Moonshot API key (.cn)", + hint: "Kimi K2.5", optionKey: "moonshotApiKey", flagName: "--moonshot-api-key", envVar: "MOONSHOT_API_KEY", @@ -66,10 +66,10 @@ const moonshotPlugin = { applyConfig: (cfg) => applyMoonshotConfigCn(cfg), wizard: { choiceId: "moonshot-api-key-cn", - choiceLabel: "Kimi API key (.cn)", + choiceLabel: "Moonshot API key (.cn)", groupId: "moonshot", groupLabel: "Moonshot AI (Kimi K2.5)", - groupHint: "Kimi K2.5 + Kimi", + groupHint: "Kimi K2.5", }, }), ], diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index 8577fc479db..66bbfd2b6c8 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -9,10 +9,10 @@ "provider": "moonshot", "method": "api-key", "choiceId": "moonshot-api-key", - "choiceLabel": "Kimi API key (.ai)", + "choiceLabel": "Moonshot API key (.ai)", "groupId": "moonshot", "groupLabel": "Moonshot AI (Kimi K2.5)", - "groupHint": "Kimi K2.5 + Kimi", + "groupHint": "Kimi K2.5", "optionKey": "moonshotApiKey", "cliFlag": "--moonshot-api-key", "cliOption": "--moonshot-api-key ", @@ -22,10 +22,10 @@ "provider": "moonshot", "method": "api-key-cn", "choiceId": "moonshot-api-key-cn", - "choiceLabel": "Kimi API key (.cn)", + "choiceLabel": "Moonshot API key (.cn)", "groupId": "moonshot", "groupLabel": "Moonshot AI (Kimi K2.5)", - "groupHint": "Kimi K2.5 + Kimi", + "groupHint": "Kimi K2.5", "optionKey": "moonshotApiKey", "cliFlag": "--moonshot-api-key", "cliOption": "--moonshot-api-key ", diff --git a/extensions/xai/model-definitions.ts b/extensions/xai/model-definitions.ts new file mode 100644 index 00000000000..5d3383eff8e --- /dev/null +++ b/extensions/xai/model-definitions.ts @@ -0,0 +1,25 @@ +import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; + +export const XAI_BASE_URL = "https://api.x.ai/v1"; +export const XAI_DEFAULT_MODEL_ID = "grok-4"; +export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`; +export const XAI_DEFAULT_CONTEXT_WINDOW = 131072; +export const XAI_DEFAULT_MAX_TOKENS = 8192; +export const XAI_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export function buildXaiModelDefinition(): ModelDefinitionConfig { + return { + id: XAI_DEFAULT_MODEL_ID, + name: "Grok 4", + reasoning: false, + input: ["text"], + cost: XAI_DEFAULT_COST, + contextWindow: XAI_DEFAULT_CONTEXT_WINDOW, + maxTokens: XAI_DEFAULT_MAX_TOKENS, + }; +} diff --git a/extensions/xai/onboard.ts b/extensions/xai/onboard.ts index 1404c6a4983..ee5cfbc92cf 100644 --- a/extensions/xai/onboard.ts +++ b/extensions/xai/onboard.ts @@ -2,14 +2,15 @@ import { applyAgentDefaultModelPrimary, applyProviderConfigWithDefaultModel, } from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; import { buildXaiModelDefinition, XAI_BASE_URL, XAI_DEFAULT_MODEL_ID, -} from "../../src/commands/onboard-auth.models.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; + XAI_DEFAULT_MODEL_REF, +} from "./model-definitions.js"; -export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`; +export { XAI_DEFAULT_MODEL_REF }; export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index f38058dd9e9..f8f524ddd79 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -19,7 +19,7 @@ import { validateApiKeyInput, } from "../../src/commands/auth-choice.api-key.js"; import { ensureApiKeyFromOptionEnvOrPrompt } from "../../src/commands/auth-choice.apply-helpers.js"; -import { buildApiKeyCredential } from "../../src/commands/onboard-auth.credentials.js"; +import { buildApiKeyCredential } from "../../src/commands/auth-credentials.js"; import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; import type { SecretInput } from "../../src/config/types.secrets.js"; import { resolveRequiredHomeDir } from "../../src/infra/home-dir.js"; diff --git a/extensions/zai/model-definitions.ts b/extensions/zai/model-definitions.ts new file mode 100644 index 00000000000..2527ee53031 --- /dev/null +++ b/extensions/zai/model-definitions.ts @@ -0,0 +1,60 @@ +import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; + +export const ZAI_CODING_GLOBAL_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; +export const ZAI_CODING_CN_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; +export const ZAI_GLOBAL_BASE_URL = "https://api.z.ai/api/paas/v4"; +export const ZAI_CN_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"; +export const ZAI_DEFAULT_MODEL_ID = "glm-5"; +export const ZAI_DEFAULT_MODEL_REF = `zai/${ZAI_DEFAULT_MODEL_ID}`; + +export const ZAI_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const ZAI_MODEL_CATALOG = { + "glm-5": { name: "GLM-5", reasoning: true }, + "glm-5-turbo": { name: "GLM-5 Turbo", reasoning: true }, + "glm-4.7": { name: "GLM-4.7", reasoning: true }, + "glm-4.7-flash": { name: "GLM-4.7 Flash", reasoning: true }, + "glm-4.7-flashx": { name: "GLM-4.7 FlashX", reasoning: true }, +} as const; + +type ZaiCatalogId = keyof typeof ZAI_MODEL_CATALOG; + +export function resolveZaiBaseUrl(endpoint?: string): string { + switch (endpoint) { + case "coding-cn": + return ZAI_CODING_CN_BASE_URL; + case "global": + return ZAI_GLOBAL_BASE_URL; + case "cn": + return ZAI_CN_BASE_URL; + case "coding-global": + return ZAI_CODING_GLOBAL_BASE_URL; + default: + return ZAI_GLOBAL_BASE_URL; + } +} + +export function buildZaiModelDefinition(params: { + id: string; + name?: string; + reasoning?: boolean; + cost?: ModelDefinitionConfig["cost"]; + contextWindow?: number; + maxTokens?: number; +}): ModelDefinitionConfig { + const catalog = ZAI_MODEL_CATALOG[params.id as ZaiCatalogId]; + return { + id: params.id, + name: params.name ?? catalog?.name ?? `GLM ${params.id}`, + reasoning: params.reasoning ?? catalog?.reasoning ?? true, + input: ["text"], + cost: params.cost ?? ZAI_DEFAULT_COST, + contextWindow: params.contextWindow ?? 204800, + maxTokens: params.maxTokens ?? 131072, + }; +} diff --git a/extensions/zai/onboard.ts b/extensions/zai/onboard.ts index 4e03994b2a7..a440387cf7b 100644 --- a/extensions/zai/onboard.ts +++ b/extensions/zai/onboard.ts @@ -2,14 +2,15 @@ import { applyAgentDefaultModelPrimary, applyProviderConfigWithModelCatalog, } from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; import { buildZaiModelDefinition, resolveZaiBaseUrl, ZAI_DEFAULT_MODEL_ID, -} from "../../src/commands/onboard-auth.models.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; + ZAI_DEFAULT_MODEL_REF, +} from "./model-definitions.js"; -export const ZAI_DEFAULT_MODEL_REF = `zai/${ZAI_DEFAULT_MODEL_ID}`; +export { ZAI_DEFAULT_MODEL_REF }; const ZAI_DEFAULT_MODELS = [ buildZaiModelDefinition({ id: "glm-5" }), diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 7fa8832e0e7..e7d583d106f 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -112,7 +112,8 @@ describe("model-selection", () => { expect(normalizeProviderId("z-ai")).toBe("zai"); expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode"); expect(normalizeProviderId("qwen")).toBe("qwen-portal"); - expect(normalizeProviderId("kimi-code")).toBe("kimi-coding"); + expect(normalizeProviderId("kimi-code")).toBe("kimi"); + expect(normalizeProviderId("kimi-coding")).toBe("kimi"); expect(normalizeProviderId("bedrock")).toBe("amazon-bedrock"); expect(normalizeProviderId("aws-bedrock")).toBe("amazon-bedrock"); expect(normalizeProviderId("amazon-bedrock")).toBe("amazon-bedrock"); diff --git a/src/agents/models-config.merge.test.ts b/src/agents/models-config.merge.test.ts index b84d4e363d6..17d2f9033fe 100644 --- a/src/agents/models-config.merge.test.ts +++ b/src/agents/models-config.merge.test.ts @@ -74,8 +74,8 @@ describe("models-config merge helpers", () => { headers: { "User-Agent": "claude-code/0.1.0" }, models: [ { - id: "k2p5", - name: "Kimi for Coding", + id: "kimi-code", + name: "Kimi Code", input: ["text", "image"], reasoning: true, }, @@ -87,8 +87,8 @@ describe("models-config merge helpers", () => { headers: { "X-Kimi-Tenant": "tenant-a" }, models: [ { - id: "k2p5", - name: "Kimi for Coding", + id: "kimi-code", + name: "Kimi Code", input: ["text", "image"], reasoning: true, }, diff --git a/src/agents/models-config.providers.kimi-coding.test.ts b/src/agents/models-config.providers.kimi-coding.test.ts index 91ca62f34e2..3da4986961a 100644 --- a/src/agents/models-config.providers.kimi-coding.test.ts +++ b/src/agents/models-config.providers.kimi-coding.test.ts @@ -6,46 +6,47 @@ import { captureEnv } from "../test-utils/env.js"; import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; import { buildKimiCodingProvider } from "./models-config.providers.js"; -describe("kimi-coding implicit provider (#22409)", () => { - it("should include kimi-coding when KIMI_API_KEY is configured", async () => { +describe("Kimi implicit provider (#22409)", () => { + it("should include Kimi when KIMI_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KIMI_API_KEY"]); process.env.KIMI_API_KEY = "test-key"; // pragma: allowlist secret try { const providers = await resolveImplicitProvidersForTest({ agentDir }); - expect(providers?.["kimi-coding"]).toBeDefined(); - expect(providers?.["kimi-coding"]?.api).toBe("anthropic-messages"); - expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://api.kimi.com/coding/"); + expect(providers?.kimi).toBeDefined(); + expect(providers?.kimi?.api).toBe("anthropic-messages"); + expect(providers?.kimi?.baseUrl).toBe("https://api.kimi.com/coding/"); } finally { envSnapshot.restore(); } }); - it("should build kimi-coding provider with anthropic-messages API", () => { + it("should build Kimi provider with anthropic-messages API", () => { const provider = buildKimiCodingProvider(); expect(provider.api).toBe("anthropic-messages"); expect(provider.baseUrl).toBe("https://api.kimi.com/coding/"); expect(provider.headers).toEqual({ "User-Agent": "claude-code/0.1.0" }); expect(provider.models).toBeDefined(); expect(provider.models.length).toBeGreaterThan(0); - expect(provider.models[0].id).toBe("k2p5"); + expect(provider.models[0].id).toBe("kimi-code"); + expect(provider.models.some((model) => model.id === "k2p5")).toBe(true); }); - it("should not include kimi-coding when no API key is configured", async () => { + it("should not include Kimi when no API key is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KIMI_API_KEY"]); delete process.env.KIMI_API_KEY; try { const providers = await resolveImplicitProvidersForTest({ agentDir }); - expect(providers?.["kimi-coding"]).toBeUndefined(); + expect(providers?.kimi).toBeUndefined(); } finally { envSnapshot.restore(); } }); - it("uses explicit kimi-coding baseUrl when provided", async () => { + it("uses explicit legacy kimi-coding baseUrl when provided", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KIMI_API_KEY"]); process.env.KIMI_API_KEY = "test-key"; @@ -61,13 +62,13 @@ describe("kimi-coding implicit provider (#22409)", () => { }, }, }); - expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://kimi.example.test/coding/"); + expect(providers?.kimi?.baseUrl).toBe("https://kimi.example.test/coding/"); } finally { envSnapshot.restore(); } }); - it("merges explicit kimi-coding headers on top of the built-in user agent", async () => { + it("merges explicit legacy kimi-coding headers on top of the built-in user agent", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KIMI_API_KEY"]); process.env.KIMI_API_KEY = "test-key"; @@ -87,7 +88,7 @@ describe("kimi-coding implicit provider (#22409)", () => { }, }, }); - expect(providers?.["kimi-coding"]?.headers).toEqual({ + expect(providers?.kimi?.headers).toEqual({ "User-Agent": "custom-kimi-client/1.0", "X-Kimi-Tenant": "tenant-a", }); diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index c4790e37dba..25395ea4827 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -908,7 +908,7 @@ describe("applyExtraParamsToAgent", () => { }); }); - it("does not rewrite tool schema for kimi-coding (native Anthropic format)", () => { + it("does not rewrite tool schema for Kimi (native Anthropic format)", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { @@ -931,12 +931,12 @@ describe("applyExtraParamsToAgent", () => { }; const agent = { streamFn: baseStreamFn }; - applyExtraParamsToAgent(agent, undefined, "kimi-coding", "k2p5", undefined, "low"); + applyExtraParamsToAgent(agent, undefined, "kimi", "kimi-code", undefined, "low"); const model = { api: "anthropic-messages", - provider: "kimi-coding", - id: "k2p5", + provider: "kimi", + id: "kimi-code", baseUrl: "https://api.kimi.com/coding/", } as Model<"anthropic-messages">; const context: Context = { messages: [] }; diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 47da838cc6a..a66cb697cb4 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -1129,13 +1129,13 @@ describe("resolveModel", () => { it("lets provider config override registry-found kimi user agent headers", () => { mockDiscoveredModel({ - provider: "kimi-coding", - modelId: "k2p5", + provider: "kimi", + modelId: "kimi-code", templateModel: { ...buildForwardCompatTemplate({ - id: "k2p5", - name: "Kimi for Coding", - provider: "kimi-coding", + id: "kimi-code", + name: "Kimi Code", + provider: "kimi", api: "anthropic-messages", baseUrl: "https://api.kimi.com/coding/", }), @@ -1146,7 +1146,7 @@ describe("resolveModel", () => { const cfg = { models: { providers: { - "kimi-coding": { + kimi: { headers: { "User-Agent": "custom-kimi-client/1.0", "X-Kimi-Tenant": "tenant-a", @@ -1156,8 +1156,9 @@ describe("resolveModel", () => { }, } as unknown as OpenClawConfig; - const result = resolveModel("kimi-coding", "k2p5", "/tmp/agent", cfg); + const result = resolveModel("kimi", "kimi-code", "/tmp/agent", cfg); expect(result.error).toBeUndefined(); + expect(result.model?.id).toBe("kimi-for-coding"); expect((result.model as unknown as { headers?: Record }).headers).toEqual({ "User-Agent": "custom-kimi-client/1.0", "X-Kimi-Tenant": "tenant-a", diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 64af7b7ffd5..e8efa015137 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1009,7 +1009,7 @@ function wrapStreamRepairMalformedToolCallArguments( if (!loggedRepairIndices.has(event.contentIndex)) { loggedRepairIndices.add(event.contentIndex); log.warn( - `repairing kimi-coding tool call arguments after ${repair.trailingSuffix.length} trailing chars`, + `repairing Kimi tool call arguments after ${repair.trailingSuffix.length} trailing chars`, ); } } else { @@ -1064,7 +1064,7 @@ export function wrapStreamFnRepairMalformedToolCallArguments(baseFn: StreamFn): } function shouldRepairMalformedAnthropicToolCallArguments(provider?: string): boolean { - return normalizeProviderId(provider ?? "") === "kimi-coding"; + return normalizeProviderId(provider ?? "") === "kimi"; } // --------------------------------------------------------------------------- diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 699cba9ffe5..fa3b12b8d4d 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -30,7 +30,7 @@ const resolveProviderCapabilitiesWithPluginMock = vi.fn((params: { provider: str geminiThoughtSignatureSanitization: true, geminiThoughtSignatureModelHints: ["gemini"], }; - case "kimi-coding": + case "kimi": return { preserveAnthropicThinkingSignatures: false, }; @@ -84,9 +84,7 @@ describe("resolveProviderCapabilities", () => { }); it("normalizes kimi aliases to the same capability set", () => { - expect(resolveProviderCapabilities("kimi-coding")).toEqual( - resolveProviderCapabilities("kimi-code"), - ); + expect(resolveProviderCapabilities("kimi")).toEqual(resolveProviderCapabilities("kimi-code")); expect(resolveProviderCapabilities("kimi-code")).toEqual({ anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", @@ -131,7 +129,7 @@ describe("resolveProviderCapabilities", () => { }); it("treats kimi aliases as native anthropic tool payload providers", () => { - expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-coding")).toBe(false); + expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi")).toBe(false); expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-code")).toBe(false); expect(requiresOpenAiCompatibleAnthropicToolPayload("anthropic")).toBe(false); }); diff --git a/src/agents/provider-id.ts b/src/agents/provider-id.ts index 79daa684534..bd82c3c3edd 100644 --- a/src/agents/provider-id.ts +++ b/src/agents/provider-id.ts @@ -12,11 +12,8 @@ export function normalizeProviderId(provider: string): string { if (normalized === "qwen") { return "qwen-portal"; } - if (normalized === "kimi-code") { - return "kimi-coding"; - } - if (normalized === "kimi") { - return "kimi-coding"; + if (normalized === "kimi" || normalized === "kimi-code" || normalized === "kimi-coding") { + return "kimi"; } if (normalized === "bedrock" || normalized === "aws-bedrock") { return "amazon-bedrock"; diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 3534bfad92b..7409e7a4b12 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -114,16 +114,16 @@ describe("resolveTranscriptPolicy", () => { preserveSignatures: false, }, { - title: "kimi-coding provider", - provider: "kimi-coding", - modelId: "k2p5", + title: "Kimi provider", + provider: "kimi", + modelId: "kimi-code", modelApi: "anthropic-messages" as const, preserveSignatures: false, }, { title: "kimi-code alias", provider: "kimi-code", - modelId: "k2p5", + modelId: "kimi-code", modelApi: "anthropic-messages" as const, preserveSignatures: false, }, diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 5b90b34d4d5..e20084ed923 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -6,7 +6,7 @@ vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(async () => [ { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, { provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" }, - { provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" }, + { provider: "kimi", id: "kimi-code", name: "Kimi Code" }, { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, ]), @@ -222,12 +222,12 @@ describe("createModelSelectionState respects session model override", () => { const state = await resolveState( makeEntry({ providerOverride: "kimi-coding", - modelOverride: "k2p5", + modelOverride: "kimi-code", }), ); - expect(state.provider).toBe("kimi-coding"); - expect(state.model).toBe("k2p5"); + expect(state.provider).toBe("kimi"); + expect(state.model).toBe("kimi-code"); }); it("falls back to default when no modelOverride is set", async () => { @@ -241,8 +241,8 @@ describe("createModelSelectionState respects session model override", () => { // From issue #14783: stored override should beat last-used fallback model. const state = await resolveState( makeEntry({ - model: "k2p5", - modelProvider: "kimi-coding", + model: "kimi-code", + modelProvider: "kimi", contextTokens: 262_000, providerOverride: "anthropic", modelOverride: "claude-opus-4-5", diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index f6ca9d29332..038c672ee14 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -520,9 +520,9 @@ describe("applyAuthChoice", () => { { tokenProvider: "KIMI-CODING", token: "sk-kimi-token-provider-test", - profileId: "kimi-coding:default", - provider: "kimi-coding", - expectedModelPrefix: "kimi-coding/", + profileId: "kimi:default", + provider: "kimi", + expectedModelPrefix: "kimi/", }, { tokenProvider: " GOOGLE ", @@ -600,9 +600,9 @@ describe("applyAuthChoice", () => { { authChoice: "kimi-code-api-key", tokenProvider: "kimi-code", - profileId: "kimi-coding:default", - provider: "kimi-coding", - modelPrefix: "kimi-coding/", + profileId: "kimi:default", + provider: "kimi", + modelPrefix: "kimi/", }, { authChoice: "xiaomi-api-key", diff --git a/src/commands/auth-credentials.ts b/src/commands/auth-credentials.ts new file mode 100644 index 00000000000..4ee69149a92 --- /dev/null +++ b/src/commands/auth-credentials.ts @@ -0,0 +1,189 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OAuthCredentials } from "@mariozechner/pi-ai"; +import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +import { upsertAuthProfile } from "../agents/auth-profiles.js"; +import { resolveStateDir } from "../config/paths.js"; +import { + coerceSecretRef, + DEFAULT_SECRET_PROVIDER_ALIAS, + type SecretInput, + type SecretRef, +} from "../config/types.secrets.js"; +import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; +import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; +import type { SecretInputMode } from "./onboard-types.js"; + +const ENV_REF_PATTERN = /^\$\{([A-Z][A-Z0-9_]*)\}$/; + +const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); + +export type ApiKeyStorageOptions = { + secretInputMode?: SecretInputMode; +}; + +export type WriteOAuthCredentialsOptions = { + syncSiblingAgents?: boolean; +}; + +function buildEnvSecretRef(id: string): SecretRef { + return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id }; +} + +function parseEnvSecretRef(value: string): SecretRef | null { + const match = ENV_REF_PATTERN.exec(value); + if (!match) { + return null; + } + return buildEnvSecretRef(match[1]); +} + +function resolveProviderDefaultEnvSecretRef(provider: string): SecretRef { + const envVars = PROVIDER_ENV_VARS[provider]; + const envVar = envVars?.find((candidate) => candidate.trim().length > 0); + if (!envVar) { + throw new Error( + `Provider "${provider}" does not have a default env var mapping for secret-input-mode=ref.`, + ); + } + return buildEnvSecretRef(envVar); +} + +function resolveApiKeySecretInput( + provider: string, + input: SecretInput, + options?: ApiKeyStorageOptions, +): SecretInput { + const coercedRef = coerceSecretRef(input); + if (coercedRef) { + return coercedRef; + } + const normalized = normalizeSecretInput(input); + const inlineEnvRef = parseEnvSecretRef(normalized); + if (inlineEnvRef) { + return inlineEnvRef; + } + if (options?.secretInputMode === "ref") { + return resolveProviderDefaultEnvSecretRef(provider); + } + return normalized; +} + +export function buildApiKeyCredential( + provider: string, + input: SecretInput, + metadata?: Record, + options?: ApiKeyStorageOptions, +): { + type: "api_key"; + provider: string; + key?: string; + keyRef?: SecretRef; + metadata?: Record; +} { + const secretInput = resolveApiKeySecretInput(provider, input, options); + if (typeof secretInput === "string") { + return { + type: "api_key", + provider, + key: secretInput, + ...(metadata ? { metadata } : {}), + }; + } + return { + type: "api_key", + provider, + keyRef: secretInput, + ...(metadata ? { metadata } : {}), + }; +} + +/** Resolve real path, returning null if the target doesn't exist. */ +function safeRealpathSync(dir: string): string | null { + try { + return fs.realpathSync(path.resolve(dir)); + } catch { + return null; + } +} + +function resolveSiblingAgentDirs(primaryAgentDir: string): string[] { + const normalized = path.resolve(primaryAgentDir); + const parentOfAgent = path.dirname(normalized); + const candidateAgentsRoot = path.dirname(parentOfAgent); + const looksLikeStandardLayout = + path.basename(normalized) === "agent" && path.basename(candidateAgentsRoot) === "agents"; + + const agentsRoot = looksLikeStandardLayout + ? candidateAgentsRoot + : path.join(resolveStateDir(), "agents"); + + const entries = (() => { + try { + return fs.readdirSync(agentsRoot, { withFileTypes: true }); + } catch { + return []; + } + })(); + const discovered = entries + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => path.join(agentsRoot, entry.name, "agent")); + + const seen = new Set(); + const result: string[] = []; + for (const dir of [normalized, ...discovered]) { + const real = safeRealpathSync(dir); + if (real && !seen.has(real)) { + seen.add(real); + result.push(real); + } + } + return result; +} + +export async function writeOAuthCredentials( + provider: string, + creds: OAuthCredentials, + agentDir?: string, + options?: WriteOAuthCredentialsOptions, +): Promise { + const email = + typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default"; + const profileId = `${provider}:${email}`; + const resolvedAgentDir = path.resolve(resolveAuthAgentDir(agentDir)); + const targetAgentDirs = options?.syncSiblingAgents + ? resolveSiblingAgentDirs(resolvedAgentDir) + : [resolvedAgentDir]; + + const credential = { + type: "oauth" as const, + provider, + ...creds, + }; + + upsertAuthProfile({ + profileId, + credential, + agentDir: resolvedAgentDir, + }); + + if (options?.syncSiblingAgents) { + const primaryReal = safeRealpathSync(resolvedAgentDir); + for (const targetAgentDir of targetAgentDirs) { + const targetReal = safeRealpathSync(targetAgentDir); + if (targetReal && primaryReal && targetReal === primaryReal) { + continue; + } + try { + upsertAuthProfile({ + profileId, + credential, + agentDir: targetAgentDir, + }); + } catch { + // Best-effort: sibling sync failure must not block primary setup. + } + } + } + return profileId; +} diff --git a/src/commands/auth-profile-config.ts b/src/commands/auth-profile-config.ts index 797135b87b2..90be398f5b0 100644 --- a/src/commands/auth-profile-config.ts +++ b/src/commands/auth-profile-config.ts @@ -1,3 +1,4 @@ +import { normalizeProviderIdForAuth } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/config.js"; export function applyAuthProfileConfig( @@ -10,7 +11,7 @@ export function applyAuthProfileConfig( preferProfileFirst?: boolean; }, ): OpenClawConfig { - const normalizedProvider = params.provider.toLowerCase(); + const normalizedProvider = normalizeProviderIdForAuth(params.provider); const profiles = { ...cfg.auth?.profiles, [params.profileId]: { @@ -21,7 +22,7 @@ export function applyAuthProfileConfig( }; const configuredProviderProfiles = Object.entries(cfg.auth?.profiles ?? {}) - .filter(([, profile]) => profile.provider.toLowerCase() === normalizedProvider) + .filter(([, profile]) => normalizeProviderIdForAuth(profile.provider) === normalizedProvider) .map(([profileId, profile]) => ({ profileId, mode: profile.mode })); // Maintain `auth.order` when it already exists. Additionally, if we detect diff --git a/src/commands/onboard-auth.config-shared.ts b/src/commands/onboard-auth.config-shared.ts index a417b19c36e..9e70eaac192 100644 --- a/src/commands/onboard-auth.config-shared.ts +++ b/src/commands/onboard-auth.config-shared.ts @@ -1,3 +1,4 @@ +import { findNormalizedProviderKey } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/config.js"; import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js"; import type { @@ -159,10 +160,17 @@ function resolveProviderModelMergeState( providerId: string, ): ProviderModelMergeState { const providers = { ...cfg.models?.providers } as Record; - const existingProvider = providers[providerId] as ModelProviderConfig | undefined; + const existingProviderKey = findNormalizedProviderKey(providers, providerId); + const existingProvider = + existingProviderKey !== undefined + ? (providers[existingProviderKey] as ModelProviderConfig | undefined) + : undefined; const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models) ? existingProvider.models : []; + if (existingProviderKey && existingProviderKey !== providerId) { + delete providers[existingProviderKey]; + } return { providers, existingProvider, existingModels }; } diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index 2973667830b..4377a8b4de3 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -1,209 +1,27 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { upsertAuthProfile } from "../agents/auth-profiles.js"; -import { resolveStateDir } from "../config/paths.js"; -import { - coerceSecretRef, - DEFAULT_SECRET_PROVIDER_ALIAS, - type SecretInput, - type SecretRef, -} from "../config/types.secrets.js"; +import type { SecretInput } from "../config/types.secrets.js"; import { KILOCODE_DEFAULT_MODEL_REF } from "../providers/kilocode-shared.js"; -import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; -import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; -import type { SecretInputMode } from "./onboard-types.js"; +import { + buildApiKeyCredential, + type ApiKeyStorageOptions, + writeOAuthCredentials, + type WriteOAuthCredentialsOptions, +} from "./auth-credentials.js"; export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js"; -export { - MISTRAL_DEFAULT_MODEL_REF, - XAI_DEFAULT_MODEL_REF, - MODELSTUDIO_DEFAULT_MODEL_REF, -} from "./onboard-auth.models.js"; +export { MISTRAL_DEFAULT_MODEL_REF } from "../../extensions/mistral/onboard.js"; +export { MODELSTUDIO_DEFAULT_MODEL_REF } from "../../extensions/modelstudio/onboard.js"; +export { XAI_DEFAULT_MODEL_REF } from "../../extensions/xai/onboard.js"; export { KILOCODE_DEFAULT_MODEL_REF }; +export { + buildApiKeyCredential, + type ApiKeyStorageOptions, + writeOAuthCredentials, + type WriteOAuthCredentialsOptions, +}; const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); -const ENV_REF_PATTERN = /^\$\{([A-Z][A-Z0-9_]*)\}$/; - -export type ApiKeyStorageOptions = { - secretInputMode?: SecretInputMode; -}; - -function buildEnvSecretRef(id: string): SecretRef { - return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id }; -} - -function parseEnvSecretRef(value: string): SecretRef | null { - const match = ENV_REF_PATTERN.exec(value); - if (!match) { - return null; - } - return buildEnvSecretRef(match[1]); -} - -function resolveProviderDefaultEnvSecretRef(provider: string): SecretRef { - const envVars = PROVIDER_ENV_VARS[provider]; - const envVar = envVars?.find((candidate) => candidate.trim().length > 0); - if (!envVar) { - throw new Error( - `Provider "${provider}" does not have a default env var mapping for secret-input-mode=ref.`, - ); - } - return buildEnvSecretRef(envVar); -} - -function resolveApiKeySecretInput( - provider: string, - input: SecretInput, - options?: ApiKeyStorageOptions, -): SecretInput { - const coercedRef = coerceSecretRef(input); - if (coercedRef) { - return coercedRef; - } - const normalized = normalizeSecretInput(input); - const inlineEnvRef = parseEnvSecretRef(normalized); - if (inlineEnvRef) { - return inlineEnvRef; - } - const useSecretRefMode = options?.secretInputMode === "ref"; // pragma: allowlist secret - if (useSecretRefMode) { - return resolveProviderDefaultEnvSecretRef(provider); - } - return normalized; -} - -export function buildApiKeyCredential( - provider: string, - input: SecretInput, - metadata?: Record, - options?: ApiKeyStorageOptions, -): { - type: "api_key"; - provider: string; - key?: string; - keyRef?: SecretRef; - metadata?: Record; -} { - const secretInput = resolveApiKeySecretInput(provider, input, options); - if (typeof secretInput === "string") { - return { - type: "api_key", - provider, - key: secretInput, - ...(metadata ? { metadata } : {}), - }; - } - return { - type: "api_key", - provider, - keyRef: secretInput, - ...(metadata ? { metadata } : {}), - }; -} - -export type WriteOAuthCredentialsOptions = { - syncSiblingAgents?: boolean; -}; - -/** Resolve real path, returning null if the target doesn't exist. */ -function safeRealpathSync(dir: string): string | null { - try { - return fs.realpathSync(path.resolve(dir)); - } catch { - return null; - } -} - -function resolveSiblingAgentDirs(primaryAgentDir: string): string[] { - const normalized = path.resolve(primaryAgentDir); - - // Derive agentsRoot from primaryAgentDir when it matches the standard - // layout (.../agents//agent). Falls back to global state dir. - const parentOfAgent = path.dirname(normalized); - const candidateAgentsRoot = path.dirname(parentOfAgent); - const looksLikeStandardLayout = - path.basename(normalized) === "agent" && path.basename(candidateAgentsRoot) === "agents"; - - const agentsRoot = looksLikeStandardLayout - ? candidateAgentsRoot - : path.join(resolveStateDir(), "agents"); - - const entries = (() => { - try { - return fs.readdirSync(agentsRoot, { withFileTypes: true }); - } catch { - return []; - } - })(); - // Include both directories and symlinks-to-directories. - const discovered = entries - .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) - .map((entry) => path.join(agentsRoot, entry.name, "agent")); - - // Deduplicate via realpath to handle symlinks and path normalization. - const seen = new Set(); - const result: string[] = []; - for (const dir of [normalized, ...discovered]) { - const real = safeRealpathSync(dir); - if (real && !seen.has(real)) { - seen.add(real); - result.push(real); - } - } - return result; -} - -export async function writeOAuthCredentials( - provider: string, - creds: OAuthCredentials, - agentDir?: string, - options?: WriteOAuthCredentialsOptions, -): Promise { - const email = - typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default"; - const profileId = `${provider}:${email}`; - const resolvedAgentDir = path.resolve(resolveAuthAgentDir(agentDir)); - const targetAgentDirs = options?.syncSiblingAgents - ? resolveSiblingAgentDirs(resolvedAgentDir) - : [resolvedAgentDir]; - - const credential = { - type: "oauth" as const, - provider, - ...creds, - }; - - // Primary write must succeed — let it throw on failure. - upsertAuthProfile({ - profileId, - credential, - agentDir: resolvedAgentDir, - }); - - // Sibling sync is best-effort — log and ignore individual failures. - if (options?.syncSiblingAgents) { - const primaryReal = safeRealpathSync(resolvedAgentDir); - for (const targetAgentDir of targetAgentDirs) { - const targetReal = safeRealpathSync(targetAgentDir); - if (targetReal && primaryReal && targetReal === primaryReal) { - continue; - } - try { - upsertAuthProfile({ - profileId, - credential, - agentDir: targetAgentDir, - }); - } catch { - // Best-effort: sibling sync failure must not block primary setup. - } - } - } - return profileId; -} - export async function setAnthropicApiKey( key: SecretInput, agentDir?: string, @@ -277,8 +95,8 @@ export async function setKimiCodingApiKey( ) { // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ - profileId: "kimi-coding:default", - credential: buildApiKeyCredential("kimi-coding", key, undefined, options), + profileId: "kimi:default", + credential: buildApiKeyCredential("kimi", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts index e9524952750..5788d0ad2ca 100644 --- a/src/commands/onboard-auth.models.ts +++ b/src/commands/onboard-auth.models.ts @@ -1,8 +1,68 @@ +import { KIMI_CODING_MODEL_REF } from "../../extensions/kimi-coding/onboard.js"; +import { + KIMI_DEFAULT_MODEL_ID as KIMI_CODING_MODEL_ID, + KIMI_CODING_BASE_URL, +} from "../../extensions/kimi-coding/provider-catalog.js"; +import { + DEFAULT_MINIMAX_BASE_URL, + MINIMAX_API_BASE_URL, + MINIMAX_API_COST, + MINIMAX_CN_API_BASE_URL, + MINIMAX_HOSTED_COST, + MINIMAX_HOSTED_MODEL_ID, + MINIMAX_HOSTED_MODEL_REF, + MINIMAX_LM_STUDIO_COST, + buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, +} from "../../extensions/minimax/model-definitions.js"; +import { + buildMistralModelDefinition, + MISTRAL_BASE_URL, + MISTRAL_DEFAULT_COST, + MISTRAL_DEFAULT_MODEL_ID, + MISTRAL_DEFAULT_MODEL_REF, +} from "../../extensions/mistral/model-definitions.js"; +import { + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_COST, + MODELSTUDIO_DEFAULT_MODEL_ID, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, + buildModelStudioDefaultModelDefinition, + buildModelStudioModelDefinition, +} from "../../extensions/modelstudio/model-definitions.js"; +import { + MOONSHOT_CN_BASE_URL, + MOONSHOT_DEFAULT_MODEL_REF, +} from "../../extensions/moonshot/onboard.js"; +import { + buildMoonshotProvider, + MOONSHOT_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, +} from "../../extensions/moonshot/provider-catalog.js"; +import { QIANFAN_DEFAULT_MODEL_REF } from "../../extensions/qianfan/onboard.js"; import { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID, } from "../../extensions/qianfan/provider-catalog.js"; -import type { ModelDefinitionConfig } from "../config/types.js"; +import { + XAI_BASE_URL, + XAI_DEFAULT_COST, + XAI_DEFAULT_MODEL_ID, + XAI_DEFAULT_MODEL_REF, + buildXaiModelDefinition, +} from "../../extensions/xai/model-definitions.js"; +import { + buildZaiModelDefinition, + resolveZaiBaseUrl, + ZAI_CN_BASE_URL, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_GLOBAL_BASE_URL, + ZAI_DEFAULT_COST, + ZAI_DEFAULT_MODEL_ID, + ZAI_GLOBAL_BASE_URL, +} from "../../extensions/zai/model-definitions.js"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; import { KILOCODE_DEFAULT_CONTEXT_WINDOW, KILOCODE_DEFAULT_COST, @@ -10,211 +70,61 @@ import { KILOCODE_DEFAULT_MODEL_ID, KILOCODE_DEFAULT_MODEL_NAME, } from "../providers/kilocode-shared.js"; + export { + DEFAULT_MINIMAX_BASE_URL, + MINIMAX_API_BASE_URL, + MINIMAX_API_COST, + MINIMAX_CN_API_BASE_URL, + MINIMAX_HOSTED_COST, + MINIMAX_HOSTED_MODEL_ID, + MINIMAX_HOSTED_MODEL_REF, + MINIMAX_LM_STUDIO_COST, + MISTRAL_BASE_URL, + MISTRAL_DEFAULT_COST, + MISTRAL_DEFAULT_MODEL_ID, + MISTRAL_DEFAULT_MODEL_REF, + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_COST, + MODELSTUDIO_DEFAULT_MODEL_ID, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, + MOONSHOT_BASE_URL, + MOONSHOT_CN_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, + MOONSHOT_DEFAULT_MODEL_REF, + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, + QIANFAN_DEFAULT_MODEL_REF, + XAI_BASE_URL, + XAI_DEFAULT_COST, + XAI_DEFAULT_MODEL_ID, + XAI_DEFAULT_MODEL_REF, + ZAI_CN_BASE_URL, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_GLOBAL_BASE_URL, + ZAI_DEFAULT_COST, + ZAI_DEFAULT_MODEL_ID, + ZAI_GLOBAL_BASE_URL, + KIMI_CODING_BASE_URL, + KIMI_CODING_MODEL_ID, + KIMI_CODING_MODEL_REF, KILOCODE_DEFAULT_CONTEXT_WINDOW, KILOCODE_DEFAULT_COST, KILOCODE_DEFAULT_MAX_TOKENS, KILOCODE_DEFAULT_MODEL_ID, + buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, + buildMistralModelDefinition, + buildModelStudioDefaultModelDefinition, + buildModelStudioModelDefinition, + buildXaiModelDefinition, + buildZaiModelDefinition, + resolveZaiBaseUrl, }; -export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1"; -export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; -export const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic"; -export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.5"; -export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; -export const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000; -export const DEFAULT_MINIMAX_MAX_TOKENS = 8192; - -export const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; -export const MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1"; -export const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5"; -export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`; -export const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; -export const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; -export const KIMI_CODING_MODEL_ID = "k2p5"; -export const KIMI_CODING_MODEL_REF = `kimi-coding/${KIMI_CODING_MODEL_ID}`; - -export { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID }; -export const QIANFAN_DEFAULT_MODEL_REF = `qianfan/${QIANFAN_DEFAULT_MODEL_ID}`; - -export const ZAI_CODING_GLOBAL_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; -export const ZAI_CODING_CN_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; -export const ZAI_GLOBAL_BASE_URL = "https://api.z.ai/api/paas/v4"; -export const ZAI_CN_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"; -export const ZAI_DEFAULT_MODEL_ID = "glm-5"; - -export function resolveZaiBaseUrl(endpoint?: string): string { - switch (endpoint) { - case "coding-cn": - return ZAI_CODING_CN_BASE_URL; - case "global": - return ZAI_GLOBAL_BASE_URL; - case "cn": - return ZAI_CN_BASE_URL; - case "coding-global": - return ZAI_CODING_GLOBAL_BASE_URL; - default: - return ZAI_GLOBAL_BASE_URL; - } -} - -// Pricing per 1M tokens (USD) — https://platform.minimaxi.com/document/Price -export const MINIMAX_API_COST = { - input: 0.3, - output: 1.2, - cacheRead: 0.03, - cacheWrite: 0.12, -}; -export const MINIMAX_HOSTED_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; -export const MINIMAX_LM_STUDIO_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; -export const MOONSHOT_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -export const ZAI_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const MINIMAX_MODEL_CATALOG = { - "MiniMax-M2.5": { name: "MiniMax M2.5", reasoning: true }, - "MiniMax-M2.5-highspeed": { name: "MiniMax M2.5 Highspeed", reasoning: true }, -} as const; - -type MinimaxCatalogId = keyof typeof MINIMAX_MODEL_CATALOG; - -const ZAI_MODEL_CATALOG = { - "glm-5": { name: "GLM-5", reasoning: true }, - "glm-5-turbo": { name: "GLM-5 Turbo", reasoning: true }, - "glm-4.7": { name: "GLM-4.7", reasoning: true }, - "glm-4.7-flash": { name: "GLM-4.7 Flash", reasoning: true }, - "glm-4.7-flashx": { name: "GLM-4.7 FlashX", reasoning: true }, -} as const; - -type ZaiCatalogId = keyof typeof ZAI_MODEL_CATALOG; - -export function buildMinimaxModelDefinition(params: { - id: string; - name?: string; - reasoning?: boolean; - cost: ModelDefinitionConfig["cost"]; - contextWindow: number; - maxTokens: number; -}): ModelDefinitionConfig { - const catalog = MINIMAX_MODEL_CATALOG[params.id as MinimaxCatalogId]; - return { - id: params.id, - name: params.name ?? catalog?.name ?? `MiniMax ${params.id}`, - reasoning: params.reasoning ?? catalog?.reasoning ?? false, - input: ["text"], - cost: params.cost, - contextWindow: params.contextWindow, - maxTokens: params.maxTokens, - }; -} - -export function buildMinimaxApiModelDefinition(modelId: string): ModelDefinitionConfig { - return buildMinimaxModelDefinition({ - id: modelId, - cost: MINIMAX_API_COST, - contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW, - maxTokens: DEFAULT_MINIMAX_MAX_TOKENS, - }); -} - export function buildMoonshotModelDefinition(): ModelDefinitionConfig { - return { - 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 const MISTRAL_BASE_URL = "https://api.mistral.ai/v1"; -export const MISTRAL_DEFAULT_MODEL_ID = "mistral-large-latest"; -export const MISTRAL_DEFAULT_MODEL_REF = `mistral/${MISTRAL_DEFAULT_MODEL_ID}`; -export const MISTRAL_DEFAULT_CONTEXT_WINDOW = 262144; -export const MISTRAL_DEFAULT_MAX_TOKENS = 262144; -export const MISTRAL_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -export function buildMistralModelDefinition(): ModelDefinitionConfig { - return { - id: MISTRAL_DEFAULT_MODEL_ID, - name: "Mistral Large", - reasoning: false, - input: ["text", "image"], - cost: MISTRAL_DEFAULT_COST, - contextWindow: MISTRAL_DEFAULT_CONTEXT_WINDOW, - maxTokens: MISTRAL_DEFAULT_MAX_TOKENS, - }; -} - -export function buildZaiModelDefinition(params: { - id: string; - name?: string; - reasoning?: boolean; - cost?: ModelDefinitionConfig["cost"]; - contextWindow?: number; - maxTokens?: number; -}): ModelDefinitionConfig { - const catalog = ZAI_MODEL_CATALOG[params.id as ZaiCatalogId]; - return { - id: params.id, - name: params.name ?? catalog?.name ?? `GLM ${params.id}`, - reasoning: params.reasoning ?? catalog?.reasoning ?? true, - input: ["text"], - cost: params.cost ?? ZAI_DEFAULT_COST, - contextWindow: params.contextWindow ?? 204800, - maxTokens: params.maxTokens ?? 131072, - }; -} - -export const XAI_BASE_URL = "https://api.x.ai/v1"; -export const XAI_DEFAULT_MODEL_ID = "grok-4"; -export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`; -export const XAI_DEFAULT_CONTEXT_WINDOW = 131072; -export const XAI_DEFAULT_MAX_TOKENS = 8192; -export const XAI_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -export function buildXaiModelDefinition(): ModelDefinitionConfig { - return { - id: XAI_DEFAULT_MODEL_ID, - name: "Grok 4", - reasoning: false, - input: ["text"], - cost: XAI_DEFAULT_COST, - contextWindow: XAI_DEFAULT_CONTEXT_WINDOW, - maxTokens: XAI_DEFAULT_MAX_TOKENS, - }; + return buildMoonshotProvider().models[0]; } export function buildKilocodeModelDefinition(): ModelDefinitionConfig { @@ -228,105 +138,3 @@ export function buildKilocodeModelDefinition(): ModelDefinitionConfig { maxTokens: KILOCODE_DEFAULT_MAX_TOKENS, }; } - -// Alibaba Cloud Model Studio Coding Plan -export const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; -export const MODELSTUDIO_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; -export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus"; -export const MODELSTUDIO_DEFAULT_MODEL_REF = `modelstudio/${MODELSTUDIO_DEFAULT_MODEL_ID}`; -export const MODELSTUDIO_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const MODELSTUDIO_MODEL_CATALOG = { - "qwen3.5-plus": { - name: "qwen3.5-plus", - reasoning: false, - input: ["text", "image"], - contextWindow: 1000000, - maxTokens: 65536, - }, - "qwen3-max-2026-01-23": { - name: "qwen3-max-2026-01-23", - reasoning: false, - input: ["text"], - contextWindow: 262144, - maxTokens: 65536, - }, - "qwen3-coder-next": { - name: "qwen3-coder-next", - reasoning: false, - input: ["text"], - contextWindow: 262144, - maxTokens: 65536, - }, - "qwen3-coder-plus": { - name: "qwen3-coder-plus", - reasoning: false, - input: ["text"], - contextWindow: 1000000, - maxTokens: 65536, - }, - "MiniMax-M2.5": { - name: "MiniMax-M2.5", - reasoning: false, - input: ["text"], - contextWindow: 1000000, - maxTokens: 65536, - }, - "glm-5": { - name: "glm-5", - reasoning: false, - input: ["text"], - contextWindow: 202752, - maxTokens: 16384, - }, - "glm-4.7": { - name: "glm-4.7", - reasoning: false, - input: ["text"], - contextWindow: 202752, - maxTokens: 16384, - }, - "kimi-k2.5": { - name: "kimi-k2.5", - reasoning: false, - input: ["text", "image"], - contextWindow: 262144, - maxTokens: 32768, - }, -} as const; - -type ModelStudioCatalogId = keyof typeof MODELSTUDIO_MODEL_CATALOG; - -export function buildModelStudioModelDefinition(params: { - id: string; - name?: string; - reasoning?: boolean; - input?: string[]; - cost?: ModelDefinitionConfig["cost"]; - contextWindow?: number; - maxTokens?: number; -}): ModelDefinitionConfig { - const catalog = MODELSTUDIO_MODEL_CATALOG[params.id as ModelStudioCatalogId]; - return { - id: params.id, - name: params.name ?? catalog?.name ?? params.id, - reasoning: params.reasoning ?? catalog?.reasoning ?? false, - input: - (params.input as ("text" | "image")[]) ?? - ([...(catalog?.input ?? ["text"])] as ("text" | "image")[]), - cost: params.cost ?? MODELSTUDIO_DEFAULT_COST, - contextWindow: params.contextWindow ?? catalog?.contextWindow ?? 262144, - maxTokens: params.maxTokens ?? catalog?.maxTokens ?? 65536, - }; -} - -export function buildModelStudioDefaultModelDefinition(): ModelDefinitionConfig { - return buildModelStudioModelDefinition({ - id: MODELSTUDIO_DEFAULT_MODEL_ID, - }); -} diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index ac923e56710..9a67a69a287 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -100,33 +100,50 @@ export { VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "../../extensions/vercel-ai- export { XAI_DEFAULT_MODEL_REF } from "../../extensions/xai/onboard.js"; export { ZAI_DEFAULT_MODEL_REF } from "../../extensions/zai/onboard.js"; export { - buildKilocodeModelDefinition, buildMinimaxApiModelDefinition, buildMinimaxModelDefinition, - buildMistralModelDefinition, - buildMoonshotModelDefinition, - buildZaiModelDefinition, DEFAULT_MINIMAX_BASE_URL, - KILOCODE_DEFAULT_MODEL_ID, - MOONSHOT_CN_BASE_URL, - QIANFAN_BASE_URL, - QIANFAN_DEFAULT_MODEL_ID, - QIANFAN_DEFAULT_MODEL_REF, - KIMI_CODING_MODEL_ID, - KIMI_CODING_MODEL_REF, MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL, MINIMAX_HOSTED_MODEL_ID, MINIMAX_HOSTED_MODEL_REF, - MOONSHOT_BASE_URL, - MOONSHOT_DEFAULT_MODEL_ID, - MOONSHOT_DEFAULT_MODEL_REF, +} from "../../extensions/minimax/model-definitions.js"; +export { KIMI_DEFAULT_MODEL_ID as KIMI_CODING_MODEL_ID } from "../../extensions/kimi-coding/provider-catalog.js"; +export { KIMI_CODING_MODEL_REF } from "../../extensions/kimi-coding/onboard.js"; +export { + buildMistralModelDefinition, MISTRAL_BASE_URL, MISTRAL_DEFAULT_MODEL_ID, +} from "../../extensions/mistral/model-definitions.js"; +export { + MOONSHOT_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, +} from "../../extensions/moonshot/provider-catalog.js"; +export { + MOONSHOT_CN_BASE_URL, + MOONSHOT_DEFAULT_MODEL_REF, +} from "../../extensions/moonshot/onboard.js"; +export { + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, +} from "../../extensions/qianfan/provider-catalog.js"; +export { QIANFAN_DEFAULT_MODEL_REF } from "../../extensions/qianfan/onboard.js"; +export { + buildXaiModelDefinition, + XAI_BASE_URL, + XAI_DEFAULT_MODEL_ID, +} from "../../extensions/xai/model-definitions.js"; +export { + buildZaiModelDefinition, resolveZaiBaseUrl, - ZAI_CODING_CN_BASE_URL, - ZAI_DEFAULT_MODEL_ID, - ZAI_CODING_GLOBAL_BASE_URL, ZAI_CN_BASE_URL, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_GLOBAL_BASE_URL, + ZAI_DEFAULT_MODEL_ID, ZAI_GLOBAL_BASE_URL, +} from "../../extensions/zai/model-definitions.js"; +export { + buildKilocodeModelDefinition, + buildMoonshotModelDefinition, + KILOCODE_DEFAULT_MODEL_ID, } from "./onboard-auth.models.js"; diff --git a/src/commands/zai-endpoint-detect.ts b/src/commands/zai-endpoint-detect.ts index b0799088559..4426b1065fe 100644 --- a/src/commands/zai-endpoint-detect.ts +++ b/src/commands/zai-endpoint-detect.ts @@ -1,10 +1,10 @@ -import { fetchWithTimeout } from "../utils/fetch-timeout.js"; import { ZAI_CN_BASE_URL, ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL, ZAI_GLOBAL_BASE_URL, -} from "./onboard-auth.models.js"; +} from "../../extensions/zai/model-definitions.js"; +import { fetchWithTimeout } from "../utils/fetch-timeout.js"; export type ZaiEndpointId = "global" | "cn" | "coding-global" | "coding-cn"; diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index fc75ed100f6..8310276d75a 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -63,7 +63,7 @@ describe("resolveCronSession", () => { modelOverride: "deepseek-v3-4bit-mlx", providerOverride: "inferencer", thinkingLevel: "high", - model: "k2p5", + model: "kimi-code", }, }); @@ -71,7 +71,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.providerOverride).toBe("inferencer"); expect(result.sessionEntry.thinkingLevel).toBe("high"); // The model field (last-used model) should also be preserved - expect(result.sessionEntry.model).toBe("k2p5"); + expect(result.sessionEntry.model).toBe("kimi-code"); }); it("handles missing modelOverride gracefully", () => { diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index b69da702a7e..8b5ffdd5c4d 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -19,9 +19,11 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): ); 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. + // dist-runtime/. Prefer that over source extensions only when the paired + // dist/ tree exists; otherwise wrappers can drift ahead of the last build. const runtimeExtensionsDir = path.join(packageRoot, "dist-runtime", "extensions"); - if (fs.existsSync(runtimeExtensionsDir)) { + const builtExtensionsDir = path.join(packageRoot, "dist", "extensions"); + if (fs.existsSync(runtimeExtensionsDir) && fs.existsSync(builtExtensionsDir)) { return runtimeExtensionsDir; } } diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 46070deab34..8700cf8226b 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -33,7 +33,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "google", "huggingface", "kilocode", - "kimi-coding", + "kimi", "minimax", "mistral", "modelstudio", @@ -62,6 +62,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ const PLUGIN_ID_ALIASES: Readonly> = { "openai-codex": "openai", + "kimi-coding": "kimi", "minimax-portal-auth": "minimax", }; diff --git a/src/plugins/provider-api-key-auth.runtime.ts b/src/plugins/provider-api-key-auth.runtime.ts index 6909bd4cc2c..010e2b3e16e 100644 --- a/src/plugins/provider-api-key-auth.runtime.ts +++ b/src/plugins/provider-api-key-auth.runtime.ts @@ -1,7 +1,7 @@ import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js"; import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js"; +import { buildApiKeyCredential } from "../commands/auth-credentials.js"; import { applyPrimaryModel } from "../commands/model-picker.js"; -import { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js"; import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; export { From e064c1198ebb2c0cc0d547c6d54495f315f47791 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 20:56:42 -0700 Subject: [PATCH 048/128] Zalo: lazy-load channel runtime paths --- extensions/zalo/src/actions.runtime.ts | 1 + extensions/zalo/src/actions.ts | 9 ++- extensions/zalo/src/channel.runtime.ts | 91 ++++++++++++++++++++++++++ extensions/zalo/src/channel.ts | 85 +++++++----------------- 4 files changed, 122 insertions(+), 64 deletions(-) create mode 100644 extensions/zalo/src/actions.runtime.ts create mode 100644 extensions/zalo/src/channel.runtime.ts diff --git a/extensions/zalo/src/actions.runtime.ts b/extensions/zalo/src/actions.runtime.ts new file mode 100644 index 00000000000..a9616ce64a5 --- /dev/null +++ b/extensions/zalo/src/actions.runtime.ts @@ -0,0 +1 @@ +export { sendMessageZalo } from "./send.js"; diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index 4f6108fa892..6f8572b01cd 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -5,7 +5,13 @@ import type { } from "openclaw/plugin-sdk/zalo"; import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo"; import { listEnabledZaloAccounts } from "./accounts.js"; -import { sendMessageZalo } from "./send.js"; + +let zaloActionsRuntimePromise: Promise | null = null; + +async function loadZaloActionsRuntime() { + zaloActionsRuntimePromise ??= import("./actions.runtime.js"); + return zaloActionsRuntimePromise; +} const providerId = "zalo"; @@ -35,6 +41,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = { }); const mediaUrl = readStringParam(params, "media", { trim: false }); + const { sendMessageZalo } = await loadZaloActionsRuntime(); const result = await sendMessageZalo(to ?? "", content ?? "", { accountId: accountId ?? undefined, mediaUrl: mediaUrl ?? undefined, diff --git a/extensions/zalo/src/channel.runtime.ts b/extensions/zalo/src/channel.runtime.ts new file mode 100644 index 00000000000..fc4488b5be8 --- /dev/null +++ b/extensions/zalo/src/channel.runtime.ts @@ -0,0 +1,91 @@ +import { createAccountStatusSink } from "openclaw/plugin-sdk/compat"; +import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/zalo"; +import { probeZalo } from "./probe.js"; +import { resolveZaloProxyFetch } from "./proxy.js"; +import { normalizeSecretInputString } from "./secret-input.js"; +import { sendMessageZalo } from "./send.js"; + +export async function notifyZaloPairingApproval(params: { + cfg: import("openclaw/plugin-sdk/zalo").OpenClawConfig; + id: string; +}) { + const { resolveZaloAccount } = await import("./accounts.js"); + const account = resolveZaloAccount({ cfg: params.cfg }); + if (!account.token) { + throw new Error("Zalo token not configured"); + } + await sendMessageZalo(params.id, PAIRING_APPROVED_MESSAGE, { + token: account.token, + }); +} + +export async function sendZaloText( + params: Parameters[2] & { + to: string; + text: string; + }, +) { + return await sendMessageZalo(params.to, params.text, params); +} + +export async function probeZaloAccount(params: { + account: import("./accounts.js").ResolvedZaloAccount; + timeoutMs?: number; +}) { + return await probeZalo( + params.account.token, + params.timeoutMs, + resolveZaloProxyFetch(params.account.config.proxy), + ); +} + +export async function startZaloGatewayAccount( + ctx: Parameters< + NonNullable["startAccount"] + >[0], +) { + const account = ctx.account; + const token = account.token.trim(); + const mode = account.config.webhookUrl ? "webhook" : "polling"; + let zaloBotLabel = ""; + const fetcher = resolveZaloProxyFetch(account.config.proxy); + try { + const probe = await probeZalo(token, 2500, fetcher); + const name = probe.ok ? probe.bot?.name?.trim() : null; + if (name) { + zaloBotLabel = ` (${name})`; + } + if (!probe.ok) { + ctx.log?.warn?.( + `[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`, + ); + } + ctx.setStatus({ + accountId: account.accountId, + bot: probe.bot, + }); + } catch (err) { + ctx.log?.warn?.( + `[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, + ); + } + const statusSink = createAccountStatusSink({ + accountId: ctx.accountId, + setStatus: ctx.setStatus, + }); + ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`); + const { monitorZaloProvider } = await import("./monitor.js"); + return monitorZaloProvider({ + token, + account, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + useWebhook: Boolean(account.config.webhookUrl), + webhookUrl: account.config.webhookUrl, + webhookSecret: normalizeSecretInputString(account.config.webhookSecret), + webhookPath: account.config.webhookPath, + fetcher, + statusSink, + }); +} diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 32ceeeff110..ed735bbd1c7 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -3,7 +3,6 @@ import { buildOpenGroupPolicyRestrictSendersWarning, buildOpenGroupPolicyWarning, collectOpenProviderGroupPolicyWarnings, - createAccountStatusSink, mapAllowFromEntries, } from "openclaw/plugin-sdk/compat"; import type { @@ -22,8 +21,6 @@ import { formatAllowFromLowercase, listDirectoryUserEntriesFromAllowFrom, isNumericTargetId, - PAIRING_APPROVED_MESSAGE, - resolveOutboundMediaUrls, sendPayloadWithChunkedTextAndMedia, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/zalo"; @@ -35,10 +32,6 @@ import { } from "./accounts.js"; import { zaloMessageActions } from "./actions.js"; import { ZaloConfigSchema } from "./config-schema.js"; -import { probeZalo } from "./probe.js"; -import { resolveZaloProxyFetch } from "./proxy.js"; -import { normalizeSecretInputString } from "./secret-input.js"; -import { sendMessageZalo } from "./send.js"; import { zaloSetupAdapter } from "./setup-core.js"; import { zaloSetupWizard } from "./setup-surface.js"; import { collectZaloStatusIssues } from "./status-issues.js"; @@ -63,6 +56,13 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined { return trimmed.replace(/^(zalo|zl):/i, ""); } +let zaloChannelRuntimePromise: Promise | null = null; + +async function loadZaloChannelRuntime() { + zaloChannelRuntimePromise ??= import("./channel.runtime.js"); + return zaloChannelRuntimePromise; +} + export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, @@ -190,13 +190,8 @@ export const zaloPlugin: ChannelPlugin = { pairing: { idLabel: "zaloUserId", normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""), - notifyApproval: async ({ cfg, id }) => { - const account = resolveZaloAccount({ cfg: cfg }); - if (!account.token) { - throw new Error("Zalo token not configured"); - } - await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token }); - }, + notifyApproval: async (params) => + await (await loadZaloChannelRuntime()).notifyZaloPairingApproval(params), }, outbound: { deliveryMode: "direct", @@ -213,14 +208,22 @@ export const zaloPlugin: ChannelPlugin = { emptyResult: { channel: "zalo", messageId: "" }, }), sendText: async ({ to, text, accountId, cfg }) => { - const result = await sendMessageZalo(to, text, { + const result = await ( + await loadZaloChannelRuntime() + ).sendZaloText({ + to, + text, accountId: accountId ?? undefined, cfg: cfg, }); return buildChannelSendResult("zalo", result); }, sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => { - const result = await sendMessageZalo(to, text, { + const result = await ( + await loadZaloChannelRuntime() + ).sendZaloText({ + to, + text, accountId: accountId ?? undefined, mediaUrl, cfg: cfg, @@ -239,7 +242,7 @@ export const zaloPlugin: ChannelPlugin = { collectStatusIssues: collectZaloStatusIssues, buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => - probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)), + await (await loadZaloChannelRuntime()).probeZaloAccount({ account, timeoutMs }), buildAccountSnapshot: ({ account, runtime }) => { const configured = Boolean(account.token?.trim()); const base = buildBaseAccountStatusSnapshot({ @@ -260,51 +263,7 @@ export const zaloPlugin: ChannelPlugin = { }, }, gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - const token = account.token.trim(); - const mode = account.config.webhookUrl ? "webhook" : "polling"; - let zaloBotLabel = ""; - const fetcher = resolveZaloProxyFetch(account.config.proxy); - try { - const probe = await probeZalo(token, 2500, fetcher); - const name = probe.ok ? probe.bot?.name?.trim() : null; - if (name) { - zaloBotLabel = ` (${name})`; - } - if (!probe.ok) { - ctx.log?.warn?.( - `[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`, - ); - } - ctx.setStatus({ - accountId: account.accountId, - bot: probe.bot, - }); - } catch (err) { - ctx.log?.warn?.( - `[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, - ); - } - const statusSink = createAccountStatusSink({ - accountId: ctx.accountId, - setStatus: ctx.setStatus, - }); - ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`); - const { monitorZaloProvider } = await import("./monitor.js"); - return monitorZaloProvider({ - token, - account, - config: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - useWebhook: Boolean(account.config.webhookUrl), - webhookUrl: account.config.webhookUrl, - webhookSecret: normalizeSecretInputString(account.config.webhookSecret), - webhookPath: account.config.webhookPath, - fetcher, - statusSink, - }); - }, + startAccount: async (ctx) => + await (await loadZaloChannelRuntime()).startZaloGatewayAccount(ctx), }, }; From c081dc52b7c65ad67ba5db66a677933b722c3240 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:58:22 -0700 Subject: [PATCH 049/128] feat(plugins): move media understanding into vendor plugins --- extensions/anthropic/index.ts | 4 +- .../anthropic/media-understanding-provider.ts | 8 + extensions/google/index.ts | 4 +- .../google/media-understanding-provider.ts | 150 ++++++++++++++++++ extensions/minimax/index.ts | 12 +- .../minimax/media-understanding-provider.ts | 14 ++ extensions/mistral/index.ts | 4 +- .../mistral/media-understanding-provider.ts | 17 ++ extensions/moonshot/index.ts | 4 +- .../moonshot/media-understanding-provider.ts | 20 ++- extensions/openai/index.ts | 4 +- .../openai/media-understanding-provider.ts | 23 +++ extensions/test-utils/plugin-runtime-mock.ts | 9 ++ extensions/zai/index.ts | 4 +- .../zai/media-understanding-provider.ts | 8 + .../providers/anthropic/index.ts | 8 - .../providers/google/audio.ts | 21 --- .../providers/google/index.ts | 12 -- .../providers/google/inline-data.ts | 93 ----------- .../providers/google/video.test.ts | 2 +- .../providers/google/video.ts | 21 --- .../providers/groq/index.ts | 5 +- .../providers/index.test.ts | 53 +++---- src/media-understanding/providers/index.ts | 20 +-- .../providers/minimax/index.ts | 14 -- .../providers/mistral/index.test.ts | 14 +- .../providers/mistral/index.ts | 14 -- .../providers/moonshot/index.ts | 10 -- .../providers/moonshot/video.test.ts | 2 +- .../audio.ts => openai-compatible-audio.ts} | 20 +-- .../providers/openai/audio.test.ts | 10 +- .../providers/openai/index.ts | 10 -- .../providers/zai/index.ts | 8 - src/media-understanding/runtime.test.ts | 92 +++++++++++ src/media-understanding/runtime.ts | 112 +++++++++++++ .../transcribe-audio.test.ts | 28 ++-- src/media-understanding/transcribe-audio.ts | 30 +--- src/plugins/registry.ts | 106 ++++++------- src/plugins/runtime/index.ts | 13 +- src/plugins/runtime/types-core.ts | 6 + 40 files changed, 602 insertions(+), 407 deletions(-) create mode 100644 extensions/anthropic/media-understanding-provider.ts create mode 100644 extensions/google/media-understanding-provider.ts create mode 100644 extensions/minimax/media-understanding-provider.ts create mode 100644 extensions/mistral/media-understanding-provider.ts rename src/media-understanding/providers/moonshot/video.ts => extensions/moonshot/media-understanding-provider.ts (82%) create mode 100644 extensions/openai/media-understanding-provider.ts create mode 100644 extensions/zai/media-understanding-provider.ts delete mode 100644 src/media-understanding/providers/anthropic/index.ts delete mode 100644 src/media-understanding/providers/google/audio.ts delete mode 100644 src/media-understanding/providers/google/index.ts delete mode 100644 src/media-understanding/providers/google/inline-data.ts delete mode 100644 src/media-understanding/providers/google/video.ts delete mode 100644 src/media-understanding/providers/minimax/index.ts delete mode 100644 src/media-understanding/providers/mistral/index.ts delete mode 100644 src/media-understanding/providers/moonshot/index.ts rename src/media-understanding/providers/{openai/audio.ts => openai-compatible-audio.ts} (78%) delete mode 100644 src/media-understanding/providers/openai/index.ts delete mode 100644 src/media-understanding/providers/zai/index.ts create mode 100644 src/media-understanding/runtime.test.ts create mode 100644 src/media-understanding/runtime.ts diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index aad11b99a5b..cf63e876354 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -23,10 +23,10 @@ import { import { buildTokenProfileId, validateAnthropicSetupToken } from "../../src/commands/auth-token.js"; import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; import { fetchClaudeUsage } from "../../src/infra/provider-usage.fetch.js"; -import { anthropicProvider } from "../../src/media-understanding/providers/anthropic/index.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import type { ProviderAuthResult } from "../../src/plugins/types.js"; import { normalizeSecretInput } from "../../src/utils/normalize-secret-input.js"; +import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js"; const PROVIDER_ID = "anthropic"; const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6"; @@ -395,7 +395,7 @@ const anthropicPlugin = { profileId: ctx.profileId, }), }); - api.registerMediaUnderstandingProvider(anthropicProvider); + api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider); }, }; diff --git a/extensions/anthropic/media-understanding-provider.ts b/extensions/anthropic/media-understanding-provider.ts new file mode 100644 index 00000000000..fbd12374e50 --- /dev/null +++ b/extensions/anthropic/media-understanding-provider.ts @@ -0,0 +1,8 @@ +import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; + +export const anthropicMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "anthropic", + capabilities: ["image"], + describeImage: describeImageWithModel, +}; diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 177de77e49d..6389dd25e48 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -7,11 +7,11 @@ import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault, } from "../../src/commands/google-gemini-model-default.js"; -import { googleProvider } from "../../src/media-understanding/providers/google/index.js"; 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 { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; +import { googleMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; const googlePlugin = { @@ -52,7 +52,7 @@ const googlePlugin = { isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), }); registerGoogleGeminiCliProvider(api); - api.registerMediaUnderstandingProvider(googleProvider); + api.registerMediaUnderstandingProvider(googleMediaUnderstandingProvider); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ id: "gemini", diff --git a/extensions/google/media-understanding-provider.ts b/extensions/google/media-understanding-provider.ts new file mode 100644 index 00000000000..559bd4c63b8 --- /dev/null +++ b/extensions/google/media-understanding-provider.ts @@ -0,0 +1,150 @@ +import { normalizeGoogleModelId } from "../../src/agents/model-id-normalization.js"; +import { parseGeminiAuth } from "../../src/infra/gemini-auth.js"; +import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import { + assertOkOrThrowHttpError, + normalizeBaseUrl, + postJsonRequest, +} from "../../src/media-understanding/providers/shared.js"; +import type { + AudioTranscriptionRequest, + AudioTranscriptionResult, + MediaUnderstandingProvider, + VideoDescriptionRequest, + VideoDescriptionResult, +} from "../../src/media-understanding/types.js"; + +export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +export const DEFAULT_GOOGLE_VIDEO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +const DEFAULT_GOOGLE_AUDIO_MODEL = "gemini-3-flash-preview"; +const DEFAULT_GOOGLE_VIDEO_MODEL = "gemini-3-flash-preview"; +const DEFAULT_GOOGLE_AUDIO_PROMPT = "Transcribe the audio."; +const DEFAULT_GOOGLE_VIDEO_PROMPT = "Describe the video."; + +async function generateGeminiInlineDataText(params: { + buffer: Buffer; + mime?: string; + apiKey: string; + baseUrl?: string; + headers?: Record; + model?: string; + prompt?: string; + timeoutMs: number; + fetchFn?: typeof fetch; + defaultBaseUrl: string; + defaultModel: string; + defaultPrompt: string; + defaultMime: string; + httpErrorLabel: string; + missingTextError: string; +}): Promise<{ text: string; model: string }> { + const fetchFn = params.fetchFn ?? fetch; + const baseUrl = normalizeBaseUrl(params.baseUrl, params.defaultBaseUrl); + const allowPrivate = Boolean(params.baseUrl?.trim()); + const model = (() => { + const trimmed = params.model?.trim(); + if (!trimmed) { + return params.defaultModel; + } + return normalizeGoogleModelId(trimmed); + })(); + const url = `${baseUrl}/models/${model}:generateContent`; + + const authHeaders = parseGeminiAuth(params.apiKey); + const headers = new Headers(params.headers); + for (const [key, value] of Object.entries(authHeaders.headers)) { + if (!headers.has(key)) { + headers.set(key, value); + } + } + + const prompt = (() => { + const trimmed = params.prompt?.trim(); + return trimmed || params.defaultPrompt; + })(); + + const body = { + contents: [ + { + role: "user", + parts: [ + { text: prompt }, + { + inline_data: { + mime_type: params.mime ?? params.defaultMime, + data: params.buffer.toString("base64"), + }, + }, + ], + }, + ], + }; + + const { response: res, release } = await postJsonRequest({ + url, + headers, + body, + timeoutMs: params.timeoutMs, + fetchFn, + allowPrivateNetwork: allowPrivate, + }); + + try { + await assertOkOrThrowHttpError(res, params.httpErrorLabel); + + const payload = (await res.json()) as { + candidates?: Array<{ + content?: { parts?: Array<{ text?: string }> }; + }>; + }; + const parts = payload.candidates?.[0]?.content?.parts ?? []; + const text = parts + .map((part) => part?.text?.trim()) + .filter(Boolean) + .join("\n"); + if (!text) { + throw new Error(params.missingTextError); + } + return { text, model }; + } finally { + await release(); + } +} + +export async function transcribeGeminiAudio( + params: AudioTranscriptionRequest, +): Promise { + const { text, model } = await generateGeminiInlineDataText({ + ...params, + defaultBaseUrl: DEFAULT_GOOGLE_AUDIO_BASE_URL, + defaultModel: DEFAULT_GOOGLE_AUDIO_MODEL, + defaultPrompt: DEFAULT_GOOGLE_AUDIO_PROMPT, + defaultMime: "audio/wav", + httpErrorLabel: "Audio transcription failed", + missingTextError: "Audio transcription response missing text", + }); + return { text, model }; +} + +export async function describeGeminiVideo( + params: VideoDescriptionRequest, +): Promise { + const { text, model } = await generateGeminiInlineDataText({ + ...params, + defaultBaseUrl: DEFAULT_GOOGLE_VIDEO_BASE_URL, + defaultModel: DEFAULT_GOOGLE_VIDEO_MODEL, + defaultPrompt: DEFAULT_GOOGLE_VIDEO_PROMPT, + defaultMime: "video/mp4", + httpErrorLabel: "Video description failed", + missingTextError: "Video description response missing text", + }); + return { text, model }; +} + +export const googleMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "google", + capabilities: ["image", "audio", "video"], + describeImage: describeImageWithModel, + transcribeAudio: transcribeGeminiAudio, + describeVideo: describeGeminiVideo, +}; diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 8325f6bb078..8dbe47f466c 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -9,11 +9,11 @@ import { import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; import { MINIMAX_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js"; import { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js"; -import { - minimaxPortalProvider, - minimaxProvider, -} from "../../src/media-understanding/providers/minimax/index.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { + minimaxMediaUnderstandingProvider, + minimaxPortalMediaUnderstandingProvider, +} from "./media-understanding-provider.js"; import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; import { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js"; import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js"; @@ -274,8 +274,8 @@ const minimaxPlugin = { ], isModernModelRef: ({ modelId }) => isModernMiniMaxModel(modelId), }); - api.registerMediaUnderstandingProvider(minimaxProvider); - api.registerMediaUnderstandingProvider(minimaxPortalProvider); + api.registerMediaUnderstandingProvider(minimaxMediaUnderstandingProvider); + api.registerMediaUnderstandingProvider(minimaxPortalMediaUnderstandingProvider); }, }; diff --git a/extensions/minimax/media-understanding-provider.ts b/extensions/minimax/media-understanding-provider.ts new file mode 100644 index 00000000000..2798bbf9593 --- /dev/null +++ b/extensions/minimax/media-understanding-provider.ts @@ -0,0 +1,14 @@ +import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; + +export const minimaxMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "minimax", + capabilities: ["image"], + describeImage: describeImageWithModel, +}; + +export const minimaxPortalMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "minimax-portal", + capabilities: ["image"], + describeImage: describeImageWithModel, +}; diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index 7e252281555..6da8e431759 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { mistralProvider } from "../../src/media-understanding/providers/mistral/index.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { mistralMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "mistral"; @@ -51,7 +51,7 @@ const mistralPlugin = { ], }, }); - api.registerMediaUnderstandingProvider(mistralProvider); + api.registerMediaUnderstandingProvider(mistralMediaUnderstandingProvider); }, }; diff --git a/extensions/mistral/media-understanding-provider.ts b/extensions/mistral/media-understanding-provider.ts new file mode 100644 index 00000000000..6ffe1f0f898 --- /dev/null +++ b/extensions/mistral/media-understanding-provider.ts @@ -0,0 +1,17 @@ +import { transcribeOpenAiCompatibleAudio } from "../../src/media-understanding/providers/openai-compatible-audio.js"; +import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; + +const DEFAULT_MISTRAL_AUDIO_BASE_URL = "https://api.mistral.ai/v1"; +const DEFAULT_MISTRAL_AUDIO_MODEL = "voxtral-mini-latest"; + +export const mistralMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "mistral", + capabilities: ["audio"], + transcribeAudio: async (req) => + await transcribeOpenAiCompatibleAudio({ + ...req, + baseUrl: req.baseUrl ?? DEFAULT_MISTRAL_AUDIO_BASE_URL, + defaultBaseUrl: DEFAULT_MISTRAL_AUDIO_BASE_URL, + defaultModel: DEFAULT_MISTRAL_AUDIO_MODEL, + }), +}; diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index c7183c3d7ce..5ecaac45219 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -7,10 +7,10 @@ import { getScopedCredentialValue, setScopedCredentialValue, } from "../../src/agents/tools/web-search-plugin-factory.js"; -import { moonshotProvider } from "../../src/media-understanding/providers/moonshot/index.js"; 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 { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyMoonshotConfig, applyMoonshotConfigCn, @@ -100,7 +100,7 @@ const moonshotPlugin = { return createMoonshotThinkingWrapper(ctx.streamFn, thinkingType); }, }); - api.registerMediaUnderstandingProvider(moonshotProvider); + api.registerMediaUnderstandingProvider(moonshotMediaUnderstandingProvider); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ id: "kimi", diff --git a/src/media-understanding/providers/moonshot/video.ts b/extensions/moonshot/media-understanding-provider.ts similarity index 82% rename from src/media-understanding/providers/moonshot/video.ts rename to extensions/moonshot/media-understanding-provider.ts index 0cc6f55a7e3..52bc9701c26 100644 --- a/src/media-understanding/providers/moonshot/video.ts +++ b/extensions/moonshot/media-understanding-provider.ts @@ -1,5 +1,14 @@ -import type { VideoDescriptionRequest, VideoDescriptionResult } from "../../types.js"; -import { assertOkOrThrowHttpError, normalizeBaseUrl, postJsonRequest } from "../shared.js"; +import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import { + assertOkOrThrowHttpError, + normalizeBaseUrl, + postJsonRequest, +} from "../../src/media-understanding/providers/shared.js"; +import type { + MediaUnderstandingProvider, + VideoDescriptionRequest, + VideoDescriptionResult, +} from "../../src/media-understanding/types.js"; export const DEFAULT_MOONSHOT_VIDEO_BASE_URL = "https://api.moonshot.ai/v1"; const DEFAULT_MOONSHOT_VIDEO_MODEL = "kimi-k2.5"; @@ -104,3 +113,10 @@ export async function describeMoonshotVideo( await release(); } } + +export const moonshotMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "moonshot", + capabilities: ["image", "video"], + describeImage: describeImageWithModel, + describeVideo: describeMoonshotVideo, +}; diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index 2fd57473693..e45c9718087 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { openaiProvider } from "../../src/media-understanding/providers/openai/index.js"; import { buildOpenAISpeechProvider } from "../../src/tts/providers/openai.js"; +import { openaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; import { buildOpenAIProvider } from "./openai-provider.js"; @@ -13,7 +13,7 @@ const openAIPlugin = { api.registerProvider(buildOpenAIProvider()); api.registerProvider(buildOpenAICodexProviderPlugin()); api.registerSpeechProvider(buildOpenAISpeechProvider()); - api.registerMediaUnderstandingProvider(openaiProvider); + api.registerMediaUnderstandingProvider(openaiMediaUnderstandingProvider); }, }; diff --git a/extensions/openai/media-understanding-provider.ts b/extensions/openai/media-understanding-provider.ts new file mode 100644 index 00000000000..c97f317bf4d --- /dev/null +++ b/extensions/openai/media-understanding-provider.ts @@ -0,0 +1,23 @@ +import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import { transcribeOpenAiCompatibleAudio } from "../../src/media-understanding/providers/openai-compatible-audio.js"; +import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; + +export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1"; +const DEFAULT_OPENAI_AUDIO_MODEL = "gpt-4o-mini-transcribe"; + +export async function transcribeOpenAiAudio( + params: import("../../src/media-understanding/types.js").AudioTranscriptionRequest, +) { + return await transcribeOpenAiCompatibleAudio({ + ...params, + defaultBaseUrl: DEFAULT_OPENAI_AUDIO_BASE_URL, + defaultModel: DEFAULT_OPENAI_AUDIO_MODEL, + }); +} + +export const openaiMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "openai", + capabilities: ["image", "audio"], + describeImage: describeImageWithModel, + transcribeAudio: transcribeOpenAiAudio, +}; diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/extensions/test-utils/plugin-runtime-mock.ts index 22521ee833d..b7ca386028b 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/extensions/test-utils/plugin-runtime-mock.ts @@ -106,6 +106,15 @@ export function createPluginRuntimeMock(overrides: DeepPartial = textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"], listVoices: vi.fn() as unknown as PluginRuntime["tts"]["listVoices"], }, + mediaUnderstanding: { + runFile: vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["runFile"], + describeImageFile: + vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["describeImageFile"], + describeVideoFile: + vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["describeVideoFile"], + transcribeAudioFile: + vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["transcribeAudioFile"], + }, stt: { transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"], }, diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index f8f524ddd79..21ddc902902 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -24,9 +24,9 @@ import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; import type { SecretInput } from "../../src/config/types.secrets.js"; import { resolveRequiredHomeDir } from "../../src/infra/home-dir.js"; import { fetchZaiUsage } from "../../src/infra/provider-usage.fetch.js"; -import { zaiProvider } from "../../src/media-understanding/providers/zai/index.js"; import { normalizeOptionalSecretInput } from "../../src/utils/normalize-secret-input.js"; import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js"; +import { zaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyZaiConfig, applyZaiProviderConfig, ZAI_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "zai"; @@ -335,7 +335,7 @@ const zaiPlugin = { fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), isCacheTtlEligible: () => true, }); - api.registerMediaUnderstandingProvider(zaiProvider); + api.registerMediaUnderstandingProvider(zaiMediaUnderstandingProvider); }, }; diff --git a/extensions/zai/media-understanding-provider.ts b/extensions/zai/media-understanding-provider.ts new file mode 100644 index 00000000000..bbd8bcc59fc --- /dev/null +++ b/extensions/zai/media-understanding-provider.ts @@ -0,0 +1,8 @@ +import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; + +export const zaiMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "zai", + capabilities: ["image"], + describeImage: describeImageWithModel, +}; diff --git a/src/media-understanding/providers/anthropic/index.ts b/src/media-understanding/providers/anthropic/index.ts deleted file mode 100644 index 35ae04a921e..00000000000 --- a/src/media-understanding/providers/anthropic/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { describeImageWithModel } from "../image.js"; - -export const anthropicProvider: MediaUnderstandingProvider = { - id: "anthropic", - capabilities: ["image"], - describeImage: describeImageWithModel, -}; diff --git a/src/media-understanding/providers/google/audio.ts b/src/media-understanding/providers/google/audio.ts deleted file mode 100644 index 5173ad3f093..00000000000 --- a/src/media-understanding/providers/google/audio.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js"; -import { generateGeminiInlineDataText } from "./inline-data.js"; - -export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; -const DEFAULT_GOOGLE_AUDIO_MODEL = "gemini-3-flash-preview"; -const DEFAULT_GOOGLE_AUDIO_PROMPT = "Transcribe the audio."; - -export async function transcribeGeminiAudio( - params: AudioTranscriptionRequest, -): Promise { - const { text, model } = await generateGeminiInlineDataText({ - ...params, - defaultBaseUrl: DEFAULT_GOOGLE_AUDIO_BASE_URL, - defaultModel: DEFAULT_GOOGLE_AUDIO_MODEL, - defaultPrompt: DEFAULT_GOOGLE_AUDIO_PROMPT, - defaultMime: "audio/wav", - httpErrorLabel: "Audio transcription failed", - missingTextError: "Audio transcription response missing text", - }); - return { text, model }; -} diff --git a/src/media-understanding/providers/google/index.ts b/src/media-understanding/providers/google/index.ts deleted file mode 100644 index 50674aac396..00000000000 --- a/src/media-understanding/providers/google/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { describeImageWithModel } from "../image.js"; -import { transcribeGeminiAudio } from "./audio.js"; -import { describeGeminiVideo } from "./video.js"; - -export const googleProvider: MediaUnderstandingProvider = { - id: "google", - capabilities: ["image", "audio", "video"], - describeImage: describeImageWithModel, - transcribeAudio: transcribeGeminiAudio, - describeVideo: describeGeminiVideo, -}; diff --git a/src/media-understanding/providers/google/inline-data.ts b/src/media-understanding/providers/google/inline-data.ts deleted file mode 100644 index 18116a54bc2..00000000000 --- a/src/media-understanding/providers/google/inline-data.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { normalizeGoogleModelId } from "../../../agents/model-id-normalization.js"; -import { parseGeminiAuth } from "../../../infra/gemini-auth.js"; -import { assertOkOrThrowHttpError, normalizeBaseUrl, postJsonRequest } from "../shared.js"; - -export async function generateGeminiInlineDataText(params: { - buffer: Buffer; - mime?: string; - apiKey: string; - baseUrl?: string; - headers?: Record; - model?: string; - prompt?: string; - timeoutMs: number; - fetchFn?: typeof fetch; - defaultBaseUrl: string; - defaultModel: string; - defaultPrompt: string; - defaultMime: string; - httpErrorLabel: string; - missingTextError: string; -}): Promise<{ text: string; model: string }> { - const fetchFn = params.fetchFn ?? fetch; - const baseUrl = normalizeBaseUrl(params.baseUrl, params.defaultBaseUrl); - const allowPrivate = Boolean(params.baseUrl?.trim()); - const model = (() => { - const trimmed = params.model?.trim(); - if (!trimmed) { - return params.defaultModel; - } - return normalizeGoogleModelId(trimmed); - })(); - const url = `${baseUrl}/models/${model}:generateContent`; - - const authHeaders = parseGeminiAuth(params.apiKey); - const headers = new Headers(params.headers); - for (const [key, value] of Object.entries(authHeaders.headers)) { - if (!headers.has(key)) { - headers.set(key, value); - } - } - - const prompt = (() => { - const trimmed = params.prompt?.trim(); - return trimmed || params.defaultPrompt; - })(); - - const body = { - contents: [ - { - role: "user", - parts: [ - { text: prompt }, - { - inline_data: { - mime_type: params.mime ?? params.defaultMime, - data: params.buffer.toString("base64"), - }, - }, - ], - }, - ], - }; - - const { response: res, release } = await postJsonRequest({ - url, - headers, - body, - timeoutMs: params.timeoutMs, - fetchFn, - allowPrivateNetwork: allowPrivate, - }); - - try { - await assertOkOrThrowHttpError(res, params.httpErrorLabel); - - const payload = (await res.json()) as { - candidates?: Array<{ - content?: { parts?: Array<{ text?: string }> }; - }>; - }; - const parts = payload.candidates?.[0]?.content?.parts ?? []; - const text = parts - .map((part) => part?.text?.trim()) - .filter(Boolean) - .join("\n"); - if (!text) { - throw new Error(params.missingTextError); - } - return { text, model }; - } finally { - await release(); - } -} diff --git a/src/media-understanding/providers/google/video.test.ts b/src/media-understanding/providers/google/video.test.ts index 772d01e2d70..c4307e4caad 100644 --- a/src/media-understanding/providers/google/video.test.ts +++ b/src/media-understanding/providers/google/video.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describeGeminiVideo } from "../../../../extensions/google/media-understanding-provider.js"; import * as ssrf from "../../../infra/net/ssrf.js"; import { withFetchPreconnect } from "../../../test-utils/fetch-mock.js"; import { createRequestCaptureJsonFetch } from "../audio.test-helpers.js"; -import { describeGeminiVideo } from "./video.js"; const TEST_NET_IP = "203.0.113.10"; diff --git a/src/media-understanding/providers/google/video.ts b/src/media-understanding/providers/google/video.ts deleted file mode 100644 index edbeccf0288..00000000000 --- a/src/media-understanding/providers/google/video.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { VideoDescriptionRequest, VideoDescriptionResult } from "../../types.js"; -import { generateGeminiInlineDataText } from "./inline-data.js"; - -export const DEFAULT_GOOGLE_VIDEO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; -const DEFAULT_GOOGLE_VIDEO_MODEL = "gemini-3-flash-preview"; -const DEFAULT_GOOGLE_VIDEO_PROMPT = "Describe the video."; - -export async function describeGeminiVideo( - params: VideoDescriptionRequest, -): Promise { - const { text, model } = await generateGeminiInlineDataText({ - ...params, - defaultBaseUrl: DEFAULT_GOOGLE_VIDEO_BASE_URL, - defaultModel: DEFAULT_GOOGLE_VIDEO_MODEL, - defaultPrompt: DEFAULT_GOOGLE_VIDEO_PROMPT, - defaultMime: "video/mp4", - httpErrorLabel: "Video description failed", - missingTextError: "Video description response missing text", - }); - return { text, model }; -} diff --git a/src/media-understanding/providers/groq/index.ts b/src/media-understanding/providers/groq/index.ts index 5f59e5702ab..0e4a2ec33e4 100644 --- a/src/media-understanding/providers/groq/index.ts +++ b/src/media-understanding/providers/groq/index.ts @@ -1,7 +1,8 @@ import type { MediaUnderstandingProvider } from "../../types.js"; -import { transcribeOpenAiCompatibleAudio } from "../openai/audio.js"; +import { transcribeOpenAiCompatibleAudio } from "../openai-compatible-audio.js"; const DEFAULT_GROQ_AUDIO_BASE_URL = "https://api.groq.com/openai/v1"; +const DEFAULT_GROQ_AUDIO_MODEL = "whisper-large-v3-turbo"; export const groqProvider: MediaUnderstandingProvider = { id: "groq", @@ -10,5 +11,7 @@ export const groqProvider: MediaUnderstandingProvider = { transcribeOpenAiCompatibleAudio({ ...req, baseUrl: req.baseUrl ?? DEFAULT_GROQ_AUDIO_BASE_URL, + defaultBaseUrl: DEFAULT_GROQ_AUDIO_BASE_URL, + defaultModel: DEFAULT_GROQ_AUDIO_MODEL, }), }; diff --git a/src/media-understanding/providers/index.test.ts b/src/media-understanding/providers/index.test.ts index 3441b3a9a25..31bc041a608 100644 --- a/src/media-understanding/providers/index.test.ts +++ b/src/media-understanding/providers/index.test.ts @@ -8,35 +8,15 @@ describe("media-understanding provider registry", () => { setActivePluginRegistry(createEmptyPluginRegistry()); }); - it("registers the Mistral provider", () => { + it("keeps core-owned fallback providers registered by default", () => { const registry = buildMediaUnderstandingRegistry(); - const provider = getMediaUnderstandingProvider("mistral", registry); + const groqProvider = getMediaUnderstandingProvider("groq", registry); + const deepgramProvider = getMediaUnderstandingProvider("deepgram", registry); - expect(provider?.id).toBe("mistral"); - expect(provider?.capabilities).toEqual(["audio"]); - }); - - it("keeps provider id normalization behavior", () => { - const registry = buildMediaUnderstandingRegistry(); - const provider = getMediaUnderstandingProvider("gemini", registry); - - expect(provider?.id).toBe("google"); - }); - - it("registers the Moonshot provider", () => { - const registry = buildMediaUnderstandingRegistry(); - const provider = getMediaUnderstandingProvider("moonshot", registry); - - expect(provider?.id).toBe("moonshot"); - expect(provider?.capabilities).toEqual(["image", "video"]); - }); - - it("registers the minimax portal provider", () => { - const registry = buildMediaUnderstandingRegistry(); - const provider = getMediaUnderstandingProvider("minimax-portal", registry); - - expect(provider?.id).toBe("minimax-portal"); - expect(provider?.capabilities).toEqual(["image"]); + expect(groqProvider?.id).toBe("groq"); + expect(groqProvider?.capabilities).toEqual(["audio"]); + expect(deepgramProvider?.id).toBe("deepgram"); + expect(deepgramProvider?.capabilities).toEqual(["audio"]); }); it("merges plugin-registered media providers into the active registry", async () => { @@ -61,4 +41,23 @@ describe("media-understanding provider registry", () => { expect(provider?.id).toBe("google"); expect(await provider?.describeVideo?.({} as never)).toEqual({ text: "plugin video" }); }); + + it("keeps provider id normalization behavior for plugin-owned providers", () => { + const pluginRegistry = createEmptyPluginRegistry(); + pluginRegistry.mediaUnderstandingProviders.push({ + pluginId: "google", + pluginName: "Google Plugin", + source: "test", + provider: { + id: "google", + capabilities: ["image", "audio", "video"], + }, + }); + setActivePluginRegistry(pluginRegistry); + + const registry = buildMediaUnderstandingRegistry(); + const provider = getMediaUnderstandingProvider("gemini", registry); + + expect(provider?.id).toBe("google"); + }); }); diff --git a/src/media-understanding/providers/index.ts b/src/media-understanding/providers/index.ts index 6c2e484dbe5..67a45fc2019 100644 --- a/src/media-understanding/providers/index.ts +++ b/src/media-understanding/providers/index.ts @@ -1,28 +1,10 @@ import { normalizeProviderId } from "../../agents/model-selection.js"; import { getActivePluginRegistry } from "../../plugins/runtime.js"; import type { MediaUnderstandingProvider } from "../types.js"; -import { anthropicProvider } from "./anthropic/index.js"; import { deepgramProvider } from "./deepgram/index.js"; -import { googleProvider } from "./google/index.js"; import { groqProvider } from "./groq/index.js"; -import { minimaxPortalProvider, minimaxProvider } from "./minimax/index.js"; -import { mistralProvider } from "./mistral/index.js"; -import { moonshotProvider } from "./moonshot/index.js"; -import { openaiProvider } from "./openai/index.js"; -import { zaiProvider } from "./zai/index.js"; -const PROVIDERS: MediaUnderstandingProvider[] = [ - groqProvider, - openaiProvider, - googleProvider, - anthropicProvider, - minimaxProvider, - minimaxPortalProvider, - moonshotProvider, - mistralProvider, - zaiProvider, - deepgramProvider, -]; +const PROVIDERS: MediaUnderstandingProvider[] = [groqProvider, deepgramProvider]; function mergeProviderIntoRegistry( registry: Map, diff --git a/src/media-understanding/providers/minimax/index.ts b/src/media-understanding/providers/minimax/index.ts deleted file mode 100644 index c9a7936f4d3..00000000000 --- a/src/media-understanding/providers/minimax/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { describeImageWithModel } from "../image.js"; - -export const minimaxProvider: MediaUnderstandingProvider = { - id: "minimax", - capabilities: ["image"], - describeImage: describeImageWithModel, -}; - -export const minimaxPortalProvider: MediaUnderstandingProvider = { - id: "minimax-portal", - capabilities: ["image"], - describeImage: describeImageWithModel, -}; diff --git a/src/media-understanding/providers/mistral/index.test.ts b/src/media-understanding/providers/mistral/index.test.ts index b368e516667..1afa3bd9265 100644 --- a/src/media-understanding/providers/mistral/index.test.ts +++ b/src/media-understanding/providers/mistral/index.test.ts @@ -1,23 +1,23 @@ import { describe, expect, it } from "vitest"; +import { mistralMediaUnderstandingProvider } from "../../../../extensions/mistral/media-understanding-provider.js"; import { createRequestCaptureJsonFetch, installPinnedHostnameTestHooks, } from "../audio.test-helpers.js"; -import { mistralProvider } from "./index.js"; installPinnedHostnameTestHooks(); -describe("mistralProvider", () => { +describe("mistralMediaUnderstandingProvider", () => { it("has expected provider metadata", () => { - expect(mistralProvider.id).toBe("mistral"); - expect(mistralProvider.capabilities).toEqual(["audio"]); - expect(mistralProvider.transcribeAudio).toBeDefined(); + expect(mistralMediaUnderstandingProvider.id).toBe("mistral"); + expect(mistralMediaUnderstandingProvider.capabilities).toEqual(["audio"]); + expect(mistralMediaUnderstandingProvider.transcribeAudio).toBeDefined(); }); it("uses Mistral base URL by default", async () => { const { fetchFn, getRequest } = createRequestCaptureJsonFetch({ text: "bonjour" }); - const result = await mistralProvider.transcribeAudio!({ + const result = await mistralMediaUnderstandingProvider.transcribeAudio!({ buffer: Buffer.from("audio-bytes"), fileName: "voice.ogg", apiKey: "test-mistral-key", // pragma: allowlist secret @@ -32,7 +32,7 @@ describe("mistralProvider", () => { it("allows overriding baseUrl", async () => { const { fetchFn, getRequest } = createRequestCaptureJsonFetch({ text: "ok" }); - await mistralProvider.transcribeAudio!({ + await mistralMediaUnderstandingProvider.transcribeAudio!({ buffer: Buffer.from("audio"), fileName: "note.mp3", apiKey: "key", // pragma: allowlist secret diff --git a/src/media-understanding/providers/mistral/index.ts b/src/media-understanding/providers/mistral/index.ts deleted file mode 100644 index ae146d84c80..00000000000 --- a/src/media-understanding/providers/mistral/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { transcribeOpenAiCompatibleAudio } from "../openai/audio.js"; - -const DEFAULT_MISTRAL_AUDIO_BASE_URL = "https://api.mistral.ai/v1"; - -export const mistralProvider: MediaUnderstandingProvider = { - id: "mistral", - capabilities: ["audio"], - transcribeAudio: (req) => - transcribeOpenAiCompatibleAudio({ - ...req, - baseUrl: req.baseUrl ?? DEFAULT_MISTRAL_AUDIO_BASE_URL, - }), -}; diff --git a/src/media-understanding/providers/moonshot/index.ts b/src/media-understanding/providers/moonshot/index.ts deleted file mode 100644 index 78a525129dc..00000000000 --- a/src/media-understanding/providers/moonshot/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { describeImageWithModel } from "../image.js"; -import { describeMoonshotVideo } from "./video.js"; - -export const moonshotProvider: MediaUnderstandingProvider = { - id: "moonshot", - capabilities: ["image", "video"], - describeImage: describeImageWithModel, - describeVideo: describeMoonshotVideo, -}; diff --git a/src/media-understanding/providers/moonshot/video.test.ts b/src/media-understanding/providers/moonshot/video.test.ts index f6ffb1ca957..0306e7927ca 100644 --- a/src/media-understanding/providers/moonshot/video.test.ts +++ b/src/media-understanding/providers/moonshot/video.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; +import { describeMoonshotVideo } from "../../../../extensions/moonshot/media-understanding-provider.js"; import { createRequestCaptureJsonFetch, installPinnedHostnameTestHooks, } from "../audio.test-helpers.js"; -import { describeMoonshotVideo } from "./video.js"; installPinnedHostnameTestHooks(); diff --git a/src/media-understanding/providers/openai/audio.ts b/src/media-understanding/providers/openai-compatible-audio.ts similarity index 78% rename from src/media-understanding/providers/openai/audio.ts rename to src/media-understanding/providers/openai-compatible-audio.ts index 26db4b0c201..669f8ddc873 100644 --- a/src/media-understanding/providers/openai/audio.ts +++ b/src/media-understanding/providers/openai-compatible-audio.ts @@ -1,29 +1,31 @@ import path from "node:path"; -import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js"; +import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../types.js"; import { assertOkOrThrowHttpError, normalizeBaseUrl, postTranscriptionRequest, requireTranscriptionText, -} from "../shared.js"; +} from "./shared.js"; -export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1"; -const DEFAULT_OPENAI_AUDIO_MODEL = "gpt-4o-mini-transcribe"; +type OpenAiCompatibleAudioParams = AudioTranscriptionRequest & { + defaultBaseUrl: string; + defaultModel: string; +}; -function resolveModel(model?: string): string { +function resolveModel(model: string | undefined, fallback: string): string { const trimmed = model?.trim(); - return trimmed || DEFAULT_OPENAI_AUDIO_MODEL; + return trimmed || fallback; } export async function transcribeOpenAiCompatibleAudio( - params: AudioTranscriptionRequest, + params: OpenAiCompatibleAudioParams, ): Promise { const fetchFn = params.fetchFn ?? fetch; - const baseUrl = normalizeBaseUrl(params.baseUrl, DEFAULT_OPENAI_AUDIO_BASE_URL); + const baseUrl = normalizeBaseUrl(params.baseUrl, params.defaultBaseUrl); const allowPrivate = Boolean(params.baseUrl?.trim()); const url = `${baseUrl}/audio/transcriptions`; - const model = resolveModel(params.model); + const model = resolveModel(params.model, params.defaultModel); const form = new FormData(); const fileName = params.fileName?.trim() || path.basename(params.fileName) || "audio"; const bytes = new Uint8Array(params.buffer); diff --git a/src/media-understanding/providers/openai/audio.test.ts b/src/media-understanding/providers/openai/audio.test.ts index aeafb6f2ae8..06366a4c3cc 100644 --- a/src/media-understanding/providers/openai/audio.test.ts +++ b/src/media-understanding/providers/openai/audio.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from "vitest"; +import { transcribeOpenAiAudio } from "../../../../extensions/openai/media-understanding-provider.js"; import { createAuthCaptureJsonFetch, createRequestCaptureJsonFetch, installPinnedHostnameTestHooks, } from "../audio.test-helpers.js"; -import { transcribeOpenAiCompatibleAudio } from "./audio.js"; installPinnedHostnameTestHooks(); -describe("transcribeOpenAiCompatibleAudio", () => { +describe("transcribeOpenAiAudio", () => { it("respects lowercase authorization header overrides", async () => { const { fetchFn, getAuthHeader } = createAuthCaptureJsonFetch({ text: "ok" }); - const result = await transcribeOpenAiCompatibleAudio({ + const result = await transcribeOpenAiAudio({ buffer: Buffer.from("audio"), fileName: "note.mp3", apiKey: "test-key", @@ -28,7 +28,7 @@ describe("transcribeOpenAiCompatibleAudio", () => { it("builds the expected request payload", async () => { const { fetchFn, getRequest } = createRequestCaptureJsonFetch({ text: "hello" }); - const result = await transcribeOpenAiCompatibleAudio({ + const result = await transcribeOpenAiAudio({ buffer: Buffer.from("audio-bytes"), fileName: "voice.wav", apiKey: "test-key", @@ -72,7 +72,7 @@ describe("transcribeOpenAiCompatibleAudio", () => { const { fetchFn } = createRequestCaptureJsonFetch({}); await expect( - transcribeOpenAiCompatibleAudio({ + transcribeOpenAiAudio({ buffer: Buffer.from("audio-bytes"), fileName: "voice.wav", apiKey: "test-key", diff --git a/src/media-understanding/providers/openai/index.ts b/src/media-understanding/providers/openai/index.ts deleted file mode 100644 index 24d01964562..00000000000 --- a/src/media-understanding/providers/openai/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { describeImageWithModel } from "../image.js"; -import { transcribeOpenAiCompatibleAudio } from "./audio.js"; - -export const openaiProvider: MediaUnderstandingProvider = { - id: "openai", - capabilities: ["image", "audio"], - describeImage: describeImageWithModel, - transcribeAudio: transcribeOpenAiCompatibleAudio, -}; diff --git a/src/media-understanding/providers/zai/index.ts b/src/media-understanding/providers/zai/index.ts deleted file mode 100644 index 337ea0a6853..00000000000 --- a/src/media-understanding/providers/zai/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { describeImageWithModel } from "../image.js"; - -export const zaiProvider: MediaUnderstandingProvider = { - id: "zai", - capabilities: ["image"], - describeImage: describeImageWithModel, -}; diff --git a/src/media-understanding/runtime.test.ts b/src/media-understanding/runtime.test.ts new file mode 100644 index 00000000000..e15648a57fd --- /dev/null +++ b/src/media-understanding/runtime.test.ts @@ -0,0 +1,92 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { describeImageFile, runMediaUnderstandingFile } from "./runtime.js"; + +describe("media-understanding runtime helpers", () => { + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("describes images through the active media-understanding registry", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-runtime-")); + const imagePath = path.join(tempDir, "sample.jpg"); + await fs.writeFile(imagePath, Buffer.from("image-bytes")); + + const pluginRegistry = createEmptyPluginRegistry(); + pluginRegistry.mediaUnderstandingProviders.push({ + pluginId: "vision-plugin", + pluginName: "Vision Plugin", + source: "test", + provider: { + id: "vision-plugin", + capabilities: ["image"], + describeImage: async () => ({ text: "image ok", model: "vision-v1" }), + }, + }); + setActivePluginRegistry(pluginRegistry); + + const cfg = { + tools: { + media: { + image: { + models: [{ provider: "vision-plugin", model: "vision-v1" }], + }, + }, + }, + } as OpenClawConfig; + + const result = await describeImageFile({ + filePath: imagePath, + mime: "image/jpeg", + cfg, + agentDir: "/tmp/agent", + }); + + expect(result).toEqual({ + text: "image ok", + provider: "vision-plugin", + model: "vision-v1", + output: { + kind: "image.description", + attachmentIndex: 0, + text: "image ok", + provider: "vision-plugin", + model: "vision-v1", + }, + }); + }); + + it("returns undefined when no media output is produced", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-runtime-")); + const imagePath = path.join(tempDir, "sample.jpg"); + await fs.writeFile(imagePath, Buffer.from("image-bytes")); + + const result = await runMediaUnderstandingFile({ + capability: "image", + filePath: imagePath, + mime: "image/jpeg", + cfg: { + tools: { + media: { + image: { + enabled: false, + }, + }, + }, + } as OpenClawConfig, + agentDir: "/tmp/agent", + }); + + expect(result).toEqual({ + text: undefined, + provider: undefined, + model: undefined, + output: undefined, + }); + }); +}); diff --git a/src/media-understanding/runtime.ts b/src/media-understanding/runtime.ts new file mode 100644 index 00000000000..e9351921dac --- /dev/null +++ b/src/media-understanding/runtime.ts @@ -0,0 +1,112 @@ +import path from "node:path"; +import type { MsgContext } from "../auto-reply/templating.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { + buildProviderRegistry, + createMediaAttachmentCache, + normalizeMediaAttachments, + runCapability, + type ActiveMediaModel, +} from "./runner.js"; +import type { MediaUnderstandingCapability, MediaUnderstandingOutput } from "./types.js"; + +const KIND_BY_CAPABILITY: Record = { + audio: "audio.transcription", + image: "image.description", + video: "video.description", +}; + +export type RunMediaUnderstandingFileParams = { + capability: MediaUnderstandingCapability; + filePath: string; + cfg: OpenClawConfig; + agentDir?: string; + mime?: string; + activeModel?: ActiveMediaModel; +}; + +export type RunMediaUnderstandingFileResult = { + text: string | undefined; + provider?: string; + model?: string; + output?: MediaUnderstandingOutput; +}; + +function buildFileContext(params: { filePath: string; mime?: string }): MsgContext { + return { + MediaPath: params.filePath, + MediaType: params.mime, + }; +} + +export async function runMediaUnderstandingFile( + params: RunMediaUnderstandingFileParams, +): Promise { + const ctx = buildFileContext(params); + const attachments = normalizeMediaAttachments(ctx); + if (attachments.length === 0) { + return { text: undefined }; + } + + const providerRegistry = buildProviderRegistry(); + const cache = createMediaAttachmentCache(attachments, { + localPathRoots: [path.dirname(params.filePath)], + }); + + try { + const result = await runCapability({ + capability: params.capability, + cfg: params.cfg, + ctx, + attachments: cache, + media: attachments, + agentDir: params.agentDir, + providerRegistry, + config: params.cfg.tools?.media?.[params.capability], + activeModel: params.activeModel, + }); + const output = result.outputs.find( + (entry) => entry.kind === KIND_BY_CAPABILITY[params.capability], + ); + const text = output?.text?.trim(); + return { + text: text || undefined, + provider: output?.provider, + model: output?.model, + output, + }; + } finally { + await cache.cleanup(); + } +} + +export async function describeImageFile(params: { + filePath: string; + cfg: OpenClawConfig; + agentDir?: string; + mime?: string; + activeModel?: ActiveMediaModel; +}): Promise { + return await runMediaUnderstandingFile({ ...params, capability: "image" }); +} + +export async function describeVideoFile(params: { + filePath: string; + cfg: OpenClawConfig; + agentDir?: string; + mime?: string; + activeModel?: ActiveMediaModel; +}): Promise { + return await runMediaUnderstandingFile({ ...params, capability: "video" }); +} + +export async function transcribeAudioFile(params: { + filePath: string; + cfg: OpenClawConfig; + agentDir?: string; + mime?: string; + activeModel?: ActiveMediaModel; +}): Promise<{ text: string | undefined }> { + const result = await runMediaUnderstandingFile({ ...params, capability: "audio" }); + return { text: result.text }; +} diff --git a/src/media-understanding/transcribe-audio.test.ts b/src/media-understanding/transcribe-audio.test.ts index 8e76cb2b9d7..3ecddc60ce3 100644 --- a/src/media-understanding/transcribe-audio.test.ts +++ b/src/media-understanding/transcribe-audio.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -const { runAudioTranscription } = vi.hoisted(() => { - const runAudioTranscription = vi.fn(); - return { runAudioTranscription }; +const { transcribeAudioFileFromRuntime } = vi.hoisted(() => { + const transcribeAudioFileFromRuntime = vi.fn(); + return { transcribeAudioFileFromRuntime }; }); -vi.mock("./audio-transcription-runner.js", () => ({ - runAudioTranscription, +vi.mock("./runtime.js", () => ({ + transcribeAudioFile: transcribeAudioFileFromRuntime, })); import { transcribeAudioFile } from "./transcribe-audio.js"; @@ -17,27 +17,23 @@ describe("transcribeAudioFile", () => { vi.clearAllMocks(); }); - it("does not force audio/wav when mime is omitted", async () => { - runAudioTranscription.mockResolvedValue({ transcript: "hello", attachments: [] }); + it("forwards file transcription requests to the shared runtime helper", async () => { + transcribeAudioFileFromRuntime.mockResolvedValue({ text: "hello" }); const result = await transcribeAudioFile({ filePath: "/tmp/note.mp3", cfg: {} as OpenClawConfig, }); - expect(runAudioTranscription).toHaveBeenCalledWith({ - ctx: { - MediaPath: "/tmp/note.mp3", - MediaType: undefined, - }, + expect(transcribeAudioFileFromRuntime).toHaveBeenCalledWith({ + filePath: "/tmp/note.mp3", cfg: {} as OpenClawConfig, - agentDir: undefined, }); expect(result).toEqual({ text: "hello" }); }); - it("returns undefined when helper returns no transcript", async () => { - runAudioTranscription.mockResolvedValue({ transcript: undefined, attachments: [] }); + it("returns undefined when the runtime helper returns no transcript", async () => { + transcribeAudioFileFromRuntime.mockResolvedValue({ text: undefined }); const result = await transcribeAudioFile({ filePath: "/tmp/missing.wav", @@ -51,7 +47,7 @@ describe("transcribeAudioFile", () => { const cfg = { tools: { media: { audio: { timeoutSeconds: 10 } } }, } as unknown as OpenClawConfig; - runAudioTranscription.mockRejectedValue(new Error("boom")); + transcribeAudioFileFromRuntime.mockRejectedValue(new Error("boom")); await expect( transcribeAudioFile({ diff --git a/src/media-understanding/transcribe-audio.ts b/src/media-understanding/transcribe-audio.ts index b2840c80ea3..c0d567b9e83 100644 --- a/src/media-understanding/transcribe-audio.ts +++ b/src/media-understanding/transcribe-audio.ts @@ -1,29 +1 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { runAudioTranscription } from "./audio-transcription-runner.js"; - -/** - * Transcribe an audio file using the configured media-understanding provider. - * - * Reads provider/model/apiKey from `tools.media.audio` in the openclaw config, - * falling back through configured models until one succeeds. - * - * This is the runtime-exposed entry point for external plugins (e.g. marmot) - * that need STT without importing internal media-understanding modules directly. - */ -export async function transcribeAudioFile(params: { - filePath: string; - cfg: OpenClawConfig; - agentDir?: string; - mime?: string; -}): Promise<{ text: string | undefined }> { - const ctx = { - MediaPath: params.filePath, - MediaType: params.mime, - }; - const { transcript } = await runAudioTranscription({ - ctx, - cfg: params.cfg, - agentDir: params.agentDir, - }); - return { text: transcript }; -} +export { transcribeAudioFile } from "./runtime.js"; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index bad444289ac..6ec51d889fc 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -574,34 +574,62 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; - const registerSpeechProvider = (record: PluginRecord, provider: SpeechProviderPlugin) => { - const id = provider.id.trim(); + const registerUniqueProviderLike = < + T extends { id: string }, + R extends { + pluginId: string; + pluginName?: string; + provider: T; + source: string; + rootDir?: string; + }, + >(params: { + record: PluginRecord; + provider: T; + kindLabel: string; + registrations: R[]; + ownedIds: string[]; + }) => { + const id = params.provider.id.trim(); + const { record, kindLabel } = params; + const missingLabel = `${kindLabel} registration missing id`; + const duplicateLabel = `${kindLabel} already registered: ${id}`; if (!id) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, - message: "speech provider registration missing id", + message: missingLabel, }); return; } - const existing = registry.speechProviders.find((entry) => entry.provider.id === id); + const existing = params.registrations.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})`, + message: `${duplicateLabel} (${existing.pluginId})`, }); return; } - record.speechProviderIds.push(id); - registry.speechProviders.push({ + params.ownedIds.push(id); + params.registrations.push({ pluginId: record.id, pluginName: record.name, - provider, + provider: params.provider, source: record.source, rootDir: record.rootDir, + } as R); + }; + + const registerSpeechProvider = (record: PluginRecord, provider: SpeechProviderPlugin) => { + registerUniqueProviderLike({ + record, + provider, + kindLabel: "speech provider", + registrations: registry.speechProviders, + ownedIds: record.speechProviderIds, }); }; @@ -609,64 +637,22 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record: PluginRecord, provider: MediaUnderstandingProviderPlugin, ) => { - const id = provider.id.trim(); - if (!id) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: "media provider registration missing id", - }); - return; - } - const existing = registry.mediaUnderstandingProviders.find((entry) => entry.provider.id === id); - if (existing) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `media provider already registered: ${id} (${existing.pluginId})`, - }); - return; - } - record.mediaUnderstandingProviderIds.push(id); - registry.mediaUnderstandingProviders.push({ - pluginId: record.id, - pluginName: record.name, + registerUniqueProviderLike({ + record, provider, - source: record.source, - rootDir: record.rootDir, + kindLabel: "media provider", + registrations: registry.mediaUnderstandingProviders, + ownedIds: record.mediaUnderstandingProviderIds, }); }; const registerWebSearchProvider = (record: PluginRecord, provider: WebSearchProviderPlugin) => { - const id = provider.id.trim(); - if (!id) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: "web search provider registration missing id", - }); - return; - } - const existing = registry.webSearchProviders.find((entry) => entry.provider.id === id); - if (existing) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `web search provider already registered: ${id} (${existing.pluginId})`, - }); - return; - } - record.webSearchProviderIds.push(id); - registry.webSearchProviders.push({ - pluginId: record.id, - pluginName: record.name, + registerUniqueProviderLike({ + record, provider, - source: record.source, - rootDir: record.rootDir, + kindLabel: "web search provider", + registrations: registry.webSearchProviders, + ownedIds: record.webSearchProviderIds, }); }; diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 3ae024aad2b..48899303e2f 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -4,7 +4,12 @@ import { resolveApiKeyForProvider as resolveApiKeyForProviderRaw, } from "../../agents/model-auth.js"; import { resolveStateDir } from "../../config/paths.js"; -import { transcribeAudioFile } from "../../media-understanding/transcribe-audio.js"; +import { + describeImageFile, + describeVideoFile, + runMediaUnderstandingFile, + transcribeAudioFile, +} from "../../media-understanding/runtime.js"; import { listSpeechVoices, textToSpeech, textToSpeechTelephony } from "../../tts/tts.js"; import { createRuntimeAgent } from "./runtime-agent.js"; import { createRuntimeChannel } from "./runtime-channel.js"; @@ -136,6 +141,12 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): system: createRuntimeSystem(), media: createRuntimeMedia(), tts: { textToSpeech, textToSpeechTelephony, listVoices: listSpeechVoices }, + mediaUnderstanding: { + runFile: runMediaUnderstandingFile, + describeImageFile, + describeVideoFile, + transcribeAudioFile, + }, stt: { transcribeAudioFile }, tools: createRuntimeTools(), channel: createRuntimeChannel(), diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index a81a6ad6545..822f0026b49 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -51,6 +51,12 @@ export type PluginRuntimeCore = { textToSpeechTelephony: typeof import("../../tts/tts.js").textToSpeechTelephony; listVoices: typeof import("../../tts/tts.js").listSpeechVoices; }; + mediaUnderstanding: { + runFile: typeof import("../../media-understanding/runtime.js").runMediaUnderstandingFile; + describeImageFile: typeof import("../../media-understanding/runtime.js").describeImageFile; + describeVideoFile: typeof import("../../media-understanding/runtime.js").describeVideoFile; + transcribeAudioFile: typeof import("../../media-understanding/runtime.js").transcribeAudioFile; + }; stt: { transcribeAudioFile: typeof import("../../media-understanding/transcribe-audio.js").transcribeAudioFile; }; From 71a79bdf5c92603de82f332125f8e762d11cc23d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 20:58:29 -0700 Subject: [PATCH 050/128] docs(plugins): document media understanding runtime --- docs/tools/plugin.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 7a92cda65f0..c1dc9398f5c 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -747,10 +747,26 @@ Notes: - If OpenClaw adds a new capability such as video generation later, define the core capability contract first, then let vendor plugins register against it. -For STT/transcription, plugins can call: +For media-understanding runtime helpers, plugins can call: ```ts -const { text } = await api.runtime.stt.transcribeAudioFile({ +const image = await api.runtime.mediaUnderstanding.describeImageFile({ + filePath: "/tmp/inbound-photo.jpg", + cfg: api.config, + agentDir: "/tmp/agent", +}); + +const video = await api.runtime.mediaUnderstanding.describeVideoFile({ + filePath: "/tmp/inbound-video.mp4", + cfg: api.config, +}); +``` + +For audio transcription, plugins can use either the media-understanding runtime +or the older STT alias: + +```ts +const { text } = await api.runtime.mediaUnderstanding.transcribeAudioFile({ filePath: "/tmp/inbound-audio.ogg", cfg: api.config, // Optional when MIME cannot be inferred reliably: @@ -760,8 +776,11 @@ const { text } = await api.runtime.stt.transcribeAudioFile({ Notes: +- `api.runtime.mediaUnderstanding.*` is the preferred shared surface for + image/audio/video understanding. - Uses core media-understanding audio configuration (`tools.media.audio`) and provider fallback order. - Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input). +- `api.runtime.stt.transcribeAudioFile(...)` remains as a compatibility alias. ## Gateway HTTP routes From 095a9f6e1d0e25a70ac0d8f7d55634fd9bbf8480 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:01:15 +0000 Subject: [PATCH 051/128] fix: handle Parallels poweroff snapshot restores --- .../parallels-discord-roundtrip/SKILL.md | 3 + scripts/e2e/parallels-linux-smoke.sh | 82 +++++++++++++++++-- scripts/e2e/parallels-macos-smoke.sh | 69 ++++++++++++++-- scripts/e2e/parallels-windows-smoke.sh | 69 ++++++++++++++-- 4 files changed, 196 insertions(+), 27 deletions(-) diff --git a/.agents/skills/parallels-discord-roundtrip/SKILL.md b/.agents/skills/parallels-discord-roundtrip/SKILL.md index 8fda0da1a23..cbfffc21446 100644 --- a/.agents/skills/parallels-discord-roundtrip/SKILL.md +++ b/.agents/skills/parallels-discord-roundtrip/SKILL.md @@ -42,10 +42,13 @@ pnpm test:parallels:macos \ ## Notes - Snapshot target: closest to `macOS 26.3.1 fresh`. +- Snapshot resolver now prefers matching `*-poweroff*` clones when the base hint also matches. That lets the harness reuse disk-only recovery snapshots without passing a longer hint. +- If Windows/Linux snapshot restore logs show `PET_QUESTION_SNAPSHOT_STATE_INCOMPATIBLE_CPU`, drop the suspended state once, create a `*-poweroff*` replacement snapshot, and rerun. The smoke scripts now auto-start restored power-off snapshots. - Harness configures Discord inside the guest; no checked-in token/config. - Use the `openclaw` wrapper for guest `message send/read`; `node openclaw.mjs message ...` does not expose the lazy message subcommands the same way. - Write `channels.discord.guilds` in one JSON object (`--strict-json`), not dotted `config set channels.discord.guilds....` paths; numeric snowflakes get treated like array indexes. - Avoid `prlctl enter` / expect for long Discord setup scripts; it line-wraps/corrupts long commands. Use `prlctl exec --current-user /bin/sh -lc ...` for the Discord config phase. +- Full 3-OS sweeps: the shared build lock is safe in parallel, but snapshot restore is still a Parallels bottleneck. Prefer serialized Windows/Linux restore-heavy reruns if the host is already under load. - Harness cleanup deletes the temporary Discord smoke messages at exit. - Per-phase logs: `/tmp/openclaw-parallels-smoke.*` - Machine summary: pass `--json` diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh index a3e3f96bb56..f857dddcf55 100644 --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -14,6 +14,9 @@ INSTALL_VERSION="" TARGET_PACKAGE_SPEC="" JSON_OUTPUT=0 KEEP_SERVER=0 +SNAPSHOT_ID="" +SNAPSHOT_STATE="" +SNAPSHOT_NAME="" MAIN_TGZ_DIR="$(mktemp -d)" MAIN_TGZ_PATH="" @@ -163,7 +166,7 @@ esac OPENAI_API_KEY_VALUE="${!OPENAI_API_KEY_ENV:-}" [[ -n "$OPENAI_API_KEY_VALUE" ]] || die "$OPENAI_API_KEY_ENV is required" -resolve_snapshot_id() { +resolve_snapshot_info() { local json hint json="$(prlctl snapshot-list "$VM_NAME" --json)" hint="$SNAPSHOT_HINT" @@ -171,28 +174,54 @@ resolve_snapshot_id() { import difflib import json import os +import re import sys payload = json.loads(os.environ["SNAPSHOT_JSON"]) hint = os.environ["SNAPSHOT_HINT"].strip().lower() best_id = None +best_meta = None best_score = -1.0 + +def aliases(name: str) -> list[str]: + values = [name] + for pattern in ( + r"^(.*)-poweroff$", + r"^(.*)-poweroff-\d{4}-\d{2}-\d{2}$", + ): + match = re.match(pattern, name) + if match: + values.append(match.group(1)) + return values + for snapshot_id, meta in payload.items(): name = str(meta.get("name", "")).strip() lowered = name.lower() score = 0.0 - if lowered == hint: - score = 10.0 - elif hint and hint in lowered: - score = 5.0 + len(hint) / max(len(lowered), 1) - else: - score = difflib.SequenceMatcher(None, hint, lowered).ratio() + for alias in aliases(lowered): + if alias == hint: + score = max(score, 10.0) + elif hint and hint in alias: + score = max(score, 5.0 + len(hint) / max(len(alias), 1)) + else: + score = max(score, difflib.SequenceMatcher(None, hint, alias).ratio()) + if str(meta.get("state", "")).lower() == "poweroff": + score += 0.5 if score > best_score: best_score = score best_id = snapshot_id + best_meta = meta if not best_id: sys.exit("no snapshot matched") -print(best_id) +print( + "\t".join( + [ + best_id, + str(best_meta.get("state", "")).strip(), + str(best_meta.get("name", "")).strip(), + ] + ) +) PY } @@ -251,10 +280,42 @@ guest_exec() { prlctl exec "$VM_NAME" "$@" } +wait_for_vm_status() { + local expected="$1" + local deadline status + deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) + while (( SECONDS < deadline )); do + status="$(prlctl status "$VM_NAME" 2>/dev/null || true)" + if [[ "$status" == *" $expected" ]]; then + return 0 + fi + sleep 1 + done + return 1 +} + +wait_for_guest_ready() { + local deadline + deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) + while (( SECONDS < deadline )); do + if guest_exec /bin/true >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + return 1 +} + restore_snapshot() { local snapshot_id="$1" say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)" prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null + if [[ "$SNAPSHOT_STATE" == "poweroff" ]]; then + wait_for_vm_status "stopped" || die "restored poweroff snapshot did not reach stopped state in $VM_NAME" + say "Start restored poweroff snapshot $SNAPSHOT_NAME" + prlctl start "$VM_NAME" >/dev/null + fi + wait_for_guest_ready || die "guest did not become ready in $VM_NAME" } bootstrap_guest() { @@ -585,13 +646,16 @@ run_upgrade_lane() { UPGRADE_AGENT_STATUS="pass" } -SNAPSHOT_ID="$(resolve_snapshot_id)" +IFS=$'\t' read -r SNAPSHOT_ID SNAPSHOT_STATE SNAPSHOT_NAME <<<"$(resolve_snapshot_info)" +[[ -n "$SNAPSHOT_ID" ]] || die "failed to resolve snapshot id" +[[ -n "$SNAPSHOT_NAME" ]] || SNAPSHOT_NAME="$SNAPSHOT_HINT" LATEST_VERSION="$(resolve_latest_version)" HOST_IP="$(resolve_host_ip)" HOST_PORT="$(resolve_host_port)" say "VM: $VM_NAME" say "Snapshot hint: $SNAPSHOT_HINT" +say "Resolved snapshot: $SNAPSHOT_NAME [$SNAPSHOT_STATE]" say "Latest npm version: $LATEST_VERSION" say "Current head: $(git rev-parse --short HEAD)" say "Run logs: $RUN_DIR" diff --git a/scripts/e2e/parallels-macos-smoke.sh b/scripts/e2e/parallels-macos-smoke.sh index fcdb940161f..5c95235f798 100644 --- a/scripts/e2e/parallels-macos-smoke.sh +++ b/scripts/e2e/parallels-macos-smoke.sh @@ -21,6 +21,9 @@ DISCORD_TOKEN_ENV="" DISCORD_TOKEN_VALUE="" DISCORD_GUILD_ID="" DISCORD_CHANNEL_ID="" +SNAPSHOT_ID="" +SNAPSHOT_STATE="" +SNAPSHOT_NAME="" GUEST_OPENCLAW_BIN="/opt/homebrew/bin/openclaw" GUEST_OPENCLAW_ENTRY="/opt/homebrew/lib/node_modules/openclaw/openclaw.mjs" GUEST_NODE_BIN="/opt/homebrew/bin/node" @@ -291,7 +294,7 @@ cleanup_discord_smoke_messages() { discord_delete_message_id_file "$RUN_DIR/upgrade.discord-host-message-id" } -resolve_snapshot_id() { +resolve_snapshot_info() { local json hint json="$(prlctl snapshot-list "$VM_NAME" --json)" hint="$SNAPSHOT_HINT" @@ -299,28 +302,54 @@ resolve_snapshot_id() { import difflib import json import os +import re import sys payload = json.loads(os.environ["SNAPSHOT_JSON"]) hint = os.environ["SNAPSHOT_HINT"].strip().lower() best_id = None +best_meta = None best_score = -1.0 + +def aliases(name: str) -> list[str]: + values = [name] + for pattern in ( + r"^(.*)-poweroff$", + r"^(.*)-poweroff-\d{4}-\d{2}-\d{2}$", + ): + match = re.match(pattern, name) + if match: + values.append(match.group(1)) + return values + for snapshot_id, meta in payload.items(): name = str(meta.get("name", "")).strip() lowered = name.lower() score = 0.0 - if lowered == hint: - score = 10.0 - elif hint and hint in lowered: - score = 5.0 + len(hint) / max(len(lowered), 1) - else: - score = difflib.SequenceMatcher(None, hint, lowered).ratio() + for alias in aliases(lowered): + if alias == hint: + score = max(score, 10.0) + elif hint and hint in alias: + score = max(score, 5.0 + len(hint) / max(len(alias), 1)) + else: + score = max(score, difflib.SequenceMatcher(None, hint, alias).ratio()) + if str(meta.get("state", "")).lower() == "poweroff": + score += 0.5 if score > best_score: best_score = score best_id = snapshot_id + best_meta = meta if not best_id: sys.exit("no snapshot matched") -print(best_id) +print( + "\t".join( + [ + best_id, + str(best_meta.get("state", "")).strip(), + str(best_meta.get("name", "")).strip(), + ] + ) +) PY } @@ -377,6 +406,20 @@ resolve_host_port() { printf '%s\n' "$HOST_PORT" } +wait_for_vm_status() { + local expected="$1" + local deadline status + deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) + while (( SECONDS < deadline )); do + status="$(prlctl status "$VM_NAME" 2>/dev/null || true)" + if [[ "$status" == *" $expected" ]]; then + return 0 + fi + sleep 1 + done + return 1 +} + wait_for_current_user() { local deadline deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) @@ -458,6 +501,11 @@ restore_snapshot() { local snapshot_id="$1" say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)" prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null + if [[ "$SNAPSHOT_STATE" == "poweroff" ]]; then + wait_for_vm_status "stopped" || die "restored poweroff snapshot did not reach stopped state in $VM_NAME" + say "Start restored poweroff snapshot $SNAPSHOT_NAME" + prlctl start "$VM_NAME" >/dev/null + fi wait_for_current_user || die "desktop user did not become ready in $VM_NAME" } @@ -1017,13 +1065,16 @@ FRESH_MAIN_STATUS="skip" UPGRADE_STATUS="skip" UPGRADE_PRECHECK_STATUS="skip" -SNAPSHOT_ID="$(resolve_snapshot_id)" +IFS=$'\t' read -r SNAPSHOT_ID SNAPSHOT_STATE SNAPSHOT_NAME <<<"$(resolve_snapshot_info)" +[[ -n "$SNAPSHOT_ID" ]] || die "failed to resolve snapshot id" +[[ -n "$SNAPSHOT_NAME" ]] || SNAPSHOT_NAME="$SNAPSHOT_HINT" LATEST_VERSION="$(resolve_latest_version)" HOST_IP="$(resolve_host_ip)" HOST_PORT="$(resolve_host_port)" say "VM: $VM_NAME" say "Snapshot hint: $SNAPSHOT_HINT" +say "Resolved snapshot: $SNAPSHOT_NAME [$SNAPSHOT_STATE]" say "Latest npm version: $LATEST_VERSION" say "Current head: $(git rev-parse --short HEAD)" if discord_smoke_enabled; then diff --git a/scripts/e2e/parallels-windows-smoke.sh b/scripts/e2e/parallels-windows-smoke.sh index e7016d22062..615dae29fe1 100644 --- a/scripts/e2e/parallels-windows-smoke.sh +++ b/scripts/e2e/parallels-windows-smoke.sh @@ -15,6 +15,9 @@ TARGET_PACKAGE_SPEC="" JSON_OUTPUT=0 KEEP_SERVER=0 CHECK_LATEST_REF=1 +SNAPSHOT_ID="" +SNAPSHOT_STATE="" +SNAPSHOT_NAME="" MAIN_TGZ_DIR="$(mktemp -d)" MAIN_TGZ_PATH="" @@ -194,7 +197,7 @@ ps_array_literal() { printf '@(%s)' "$joined" } -resolve_snapshot_id() { +resolve_snapshot_info() { local json hint json="$(prlctl snapshot-list "$VM_NAME" --json)" hint="$SNAPSHOT_HINT" @@ -202,28 +205,54 @@ resolve_snapshot_id() { import difflib import json import os +import re import sys payload = json.loads(os.environ["SNAPSHOT_JSON"]) hint = os.environ["SNAPSHOT_HINT"].strip().lower() best_id = None +best_meta = None best_score = -1.0 + +def aliases(name: str) -> list[str]: + values = [name] + for pattern in ( + r"^(.*)-poweroff$", + r"^(.*)-poweroff-\d{4}-\d{2}-\d{2}$", + ): + match = re.match(pattern, name) + if match: + values.append(match.group(1)) + return values + for snapshot_id, meta in payload.items(): name = str(meta.get("name", "")).strip() lowered = name.lower() score = 0.0 - if lowered == hint: - score = 10.0 - elif hint and hint in lowered: - score = 5.0 + len(hint) / max(len(lowered), 1) - else: - score = difflib.SequenceMatcher(None, hint, lowered).ratio() + for alias in aliases(lowered): + if alias == hint: + score = max(score, 10.0) + elif hint and hint in alias: + score = max(score, 5.0 + len(hint) / max(len(alias), 1)) + else: + score = max(score, difflib.SequenceMatcher(None, hint, alias).ratio()) + if str(meta.get("state", "")).lower() == "poweroff": + score += 0.5 if score > best_score: best_score = score best_id = snapshot_id + best_meta = meta if not best_id: sys.exit("no snapshot matched") -print(best_id) +print( + "\t".join( + [ + best_id, + str(best_meta.get("state", "")).strip(), + str(best_meta.get("name", "")).strip(), + ] + ) +) PY } @@ -338,12 +367,31 @@ restore_snapshot() { local snapshot_id="$1" say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)" prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null + if [[ "$SNAPSHOT_STATE" == "poweroff" ]]; then + wait_for_vm_status "stopped" || die "restored poweroff snapshot did not reach stopped state in $VM_NAME" + say "Start restored poweroff snapshot $SNAPSHOT_NAME" + prlctl start "$VM_NAME" >/dev/null + fi } verify_windows_user_ready() { guest_exec cmd.exe /d /s /c "echo ready" } +wait_for_vm_status() { + local expected="$1" + local deadline status + deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) + while (( SECONDS < deadline )); do + status="$(prlctl status "$VM_NAME" 2>/dev/null || true)" + if [[ "$status" == *" $expected" ]]; then + return 0 + fi + sleep 1 + done + return 1 +} + wait_for_guest_ready() { local deadline deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) @@ -830,13 +878,16 @@ run_upgrade_lane() { UPGRADE_AGENT_STATUS="pass" } -SNAPSHOT_ID="$(resolve_snapshot_id)" +IFS=$'\t' read -r SNAPSHOT_ID SNAPSHOT_STATE SNAPSHOT_NAME <<<"$(resolve_snapshot_info)" +[[ -n "$SNAPSHOT_ID" ]] || die "failed to resolve snapshot id" +[[ -n "$SNAPSHOT_NAME" ]] || SNAPSHOT_NAME="$SNAPSHOT_HINT" LATEST_VERSION="$(resolve_latest_version)" HOST_IP="$(resolve_host_ip)" HOST_PORT="$(resolve_host_port)" say "VM: $VM_NAME" say "Snapshot hint: $SNAPSHOT_HINT" +say "Resolved snapshot: $SNAPSHOT_NAME [$SNAPSHOT_STATE]" say "Latest npm version: $LATEST_VERSION" say "Current head: $(git rev-parse --short HEAD)" say "Run logs: $RUN_DIR" From f90d432de33225628cf765edab38d56ed78559b4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 21:01:10 -0700 Subject: [PATCH 052/128] Plugins: honor native command aliases at dispatch --- .../native-command.plugin-dispatch.test.ts | 52 +++++++++++++++++++ .../src/bot-native-commands.registry.test.ts | 48 +++++++++++++++++ src/plugins/commands.test.ts | 24 +++++++++ src/plugins/commands.ts | 24 ++++++++- 4 files changed, 147 insertions(+), 1 deletion(-) 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 97401cec0d8..dc81bc72e00 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -212,6 +212,58 @@ describe("Discord native plugin command dispatch", () => { ); }); + it("round-trips Discord native aliases through the real plugin registry", async () => { + const cfg = createConfig(); + const commandSpec: NativeCommandSpec = { + name: "pairdiscord", + 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", + nativeNames: { + telegram: "pair_device", + discord: "pairdiscord", + }, + 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: { diff --git a/extensions/telegram/src/bot-native-commands.registry.test.ts b/extensions/telegram/src/bot-native-commands.registry.test.ts index d264a059505..a6fb431c349 100644 --- a/extensions/telegram/src/bot-native-commands.registry.test.ts +++ b/extensions/telegram/src/bot-native-commands.registry.test.ts @@ -147,6 +147,54 @@ describe("registerTelegramNativeCommands real plugin registry", () => { expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); }); + it("round-trips Telegram native aliases through the real plugin registry", async () => { + const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot(); + + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + nativeNames: { + telegram: "pair_device", + discord: "pairdiscord", + }, + 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_device", description: "Pair device" }]), + ); + + const handler = commandHandlers.get("pair_device"); + expect(handler).toBeTruthy(); + + await handler?.({ + match: "now", + message: { + message_id: 2, + 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(); diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index d95a98b18d9..d41841be380 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -7,6 +7,7 @@ import { executePluginCommand, getPluginCommandSpecs, listPluginCommands, + matchPluginCommand, registerPluginCommand, } from "./commands.js"; import { setActivePluginRegistry } from "./runtime.js"; @@ -107,6 +108,29 @@ describe("registerPluginCommand", () => { expect(getPluginCommandSpecs("slack")).toEqual([]); }); + it("matches provider-specific native aliases back to the canonical command", () => { + const result = registerPluginCommand("demo-plugin", { + name: "voice", + nativeNames: { + default: "talkvoice", + discord: "discordvoice", + }, + description: "Demo command", + acceptsArgs: true, + handler: async () => ({ text: "ok" }), + }); + + expect(result).toEqual({ ok: true }); + expect(matchPluginCommand("/talkvoice now")).toMatchObject({ + command: expect.objectContaining({ name: "voice", pluginId: "demo-plugin" }), + args: "now", + }); + expect(matchPluginCommand("/discordvoice now")).toMatchObject({ + command: expect.objectContaining({ name: "voice", pluginId: "demo-plugin" }), + args: "now", + }); + }); + it("resolves Discord DM command bindings with the user target prefix intact", () => { expect( __testing.resolveBindingConversationFromCommand({ diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index fdd71d4f31c..945d5cbfb15 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -219,7 +219,11 @@ export function matchPluginCommand( const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim(); const key = commandName.toLowerCase(); - const command = pluginCommands.get(key); + const command = + pluginCommands.get(key) ?? + Array.from(pluginCommands.values()).find((candidate) => + listPluginInvocationNames(candidate).includes(key), + ); if (!command) { return null; @@ -458,6 +462,24 @@ function resolvePluginNativeName( return command.name; } +function listPluginInvocationNames(command: OpenClawPluginCommandDefinition): string[] { + const names = new Set(); + const push = (value: string | undefined) => { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return; + } + names.add(`/${normalized}`); + }; + + push(command.name); + push(command.nativeNames?.default); + push(command.nativeNames?.telegram); + push(command.nativeNames?.discord); + + return [...names]; +} + /** * Get plugin command specs for native command registration (e.g., Telegram). */ From 75b8117f8352f9c36fdf83fef472a5cdf138001d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 01:46:21 +0000 Subject: [PATCH 053/128] refactor(slack): share plugin base config --- extensions/slack/src/channel.setup.ts | 72 +++++------------------ extensions/slack/src/channel.ts | 84 ++++++++------------------- extensions/slack/src/shared.ts | 84 ++++++++++++++++++++++++--- 3 files changed, 115 insertions(+), 125 deletions(-) diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index f523e2a4d71..003c33e04b4 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -1,61 +1,19 @@ -import { - buildChannelConfigSchema, - getChatChannelMeta, - SlackConfigSchema, - type ChannelPlugin, -} from "../../../src/plugin-sdk-internal/slack.js"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/slack"; import { type ResolvedSlackAccount } from "./accounts.js"; -import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; -import { - isSlackPluginAccountConfigured, - slackConfigAccessors, - slackConfigBase, - slackSetupWizard, -} from "./plugin-shared.js"; -import { slackSetupAdapter } from "./setup-core.js"; +import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; +import { createSlackPluginBase } from "./shared.js"; + +async function loadSlackChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ + slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, +})); export const slackSetupPlugin: ChannelPlugin = { - id: "slack", - meta: { - ...getChatChannelMeta("slack"), - preferSessionLookupForAnnounceTarget: true, - }, - setupWizard: slackSetupWizard, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - agentPrompt: { - messageToolHints: ({ cfg, accountId }) => - isSlackInteractiveRepliesEnabled({ cfg, accountId }) - ? [ - "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", - "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", - ] - : [ - "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", - ], - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.slack"] }, - configSchema: buildChannelConfigSchema(SlackConfigSchema), - config: { - ...slackConfigBase, - isConfigured: (account) => isSlackPluginAccountConfigured(account), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: isSlackPluginAccountConfigured(account), - botTokenSource: account.botTokenSource, - appTokenSource: account.appTokenSource, - }), - ...slackConfigAccessors, - }, - setup: slackSetupAdapter, + ...createSlackPluginBase({ + setupWizard: slackSetupWizard, + setup: slackSetupAdapter, + }), }; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 8005a29f76f..e1c515576d9 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,20 +1,17 @@ -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, - collectOpenGroupPolicyConfiguredRouteWarnings, collectOpenProviderGroupPolicyWarnings, -} from "../../../src/plugin-sdk-internal/channel-config.js"; + collectOpenGroupPolicyConfiguredRouteWarnings, +} from "openclaw/plugin-sdk/compat"; import { buildAgentSessionKey, resolveThreadSessionKeys, type RoutePeer, -} from "../../../src/plugin-sdk-internal/core.js"; +} from "openclaw/plugin-sdk/core"; import { buildComputedAccountStatusSnapshot, - buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, - getChatChannelMeta, listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, looksLikeSlackTargetId, @@ -24,10 +21,10 @@ import { resolveConfiguredFromRequiredCredentialStatuses, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, - SlackConfigSchema, type ChannelPlugin, type OpenClawConfig, -} from "../../../src/plugin-sdk-internal/slack.js"; +} from "openclaw/plugin-sdk/slack"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listEnabledSlackAccounts, @@ -41,23 +38,25 @@ import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { handleSlackMessageAction } from "./message-action-dispatch.js"; import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; -import { - isSlackPluginAccountConfigured, - slackConfigAccessors, - slackConfigBase, - slackSetupWizard, -} from "./plugin-shared.js"; import type { SlackProbe } from "./probe.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { getSlackRuntime } from "./runtime.js"; import { fetchSlackScopes } from "./scopes.js"; -import { slackSetupAdapter } from "./setup-core.js"; +import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; +import { + createSlackPluginBase, + isSlackPluginAccountConfigured, + slackConfigAccessors, +} from "./shared.js"; import { parseSlackTarget } from "./targets.js"; import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; -const meta = getChatChannelMeta("slack"); const SLACK_CHANNEL_TYPE_CACHE = new Map(); +async function loadSlackChannelRuntime() { + return await import("./channel.runtime.js"); +} + // Select the appropriate Slack token for read/write operations. function getTokenForOperation( account: ResolvedSlackAccount, @@ -329,13 +328,15 @@ async function resolveSlackAllowlistNames(params: { return await resolveSlackUserAllowlist({ token, entries: params.entries }); } +const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ + slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, +})); + export const slackPlugin: ChannelPlugin = { - id: "slack", - meta: { - ...meta, - preferSessionLookupForAnnounceTarget: true, - }, - setupWizard: slackSetupWizard, + ...createSlackPluginBase({ + setupWizard: slackSetupWizard, + setup: slackSetupAdapter, + }), pairing: { idLabel: "slackUserId", normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), @@ -364,42 +365,6 @@ export const slackPlugin: ChannelPlugin = { } }, }, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - agentPrompt: { - messageToolHints: ({ cfg, accountId }) => - isSlackInteractiveRepliesEnabled({ cfg, accountId }) - ? [ - "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", - "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", - ] - : [ - "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", - ], - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.slack"] }, - configSchema: buildChannelConfigSchema(SlackConfigSchema), - config: { - ...slackConfigBase, - isConfigured: (account) => isSlackPluginAccountConfigured(account), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: isSlackPluginAccountConfigured(account), - botTokenSource: account.botTokenSource, - appTokenSource: account.appTokenSource, - }), - ...slackConfigAccessors, - }, allowlist: { supportsScope: ({ scope }) => scope === "dm", readConfig: ({ cfg, accountId }) => @@ -569,14 +534,13 @@ export const slackPlugin: ChannelPlugin = { extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => await handleSlackMessageAction({ - providerId: meta.id, + providerId: "slack", ctx, includeReadThreadId: true, invoke: async (action, cfg, toolContext) => await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), }), }, - setup: slackSetupAdapter, outbound: { deliveryMode: "direct", chunker: null, diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index de7238a7a78..e7276da9ae1 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -1,14 +1,18 @@ -import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + SlackConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/slack"; +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 "openclaw/plugin-sdk/channel-config-helpers"; -import { - formatDocsLink, - hasConfiguredSecretInput, - patchChannelConfigForAccount, - type OpenClawConfig, -} from "openclaw/plugin-sdk/setup"; +} from "../../../src/plugin-sdk/channel-config-helpers.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, @@ -16,6 +20,7 @@ import { resolveSlackAccount, type ResolvedSlackAccount, } from "./accounts.js"; +import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; export const SLACK_CHANNEL = "slack" as const; @@ -152,3 +157,66 @@ export const slackConfigBase = createScopedChannelConfigBase({ defaultAccountId: resolveDefaultSlackAccountId, clearBaseFields: ["botToken", "appToken", "name"], }); + +export function createSlackPluginBase(params: { + setupWizard: NonNullable["setupWizard"]>; + setup: NonNullable["setup"]>; +}): Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "agentPrompt" + | "streaming" + | "reload" + | "configSchema" + | "config" + | "setup" +> { + return { + id: SLACK_CHANNEL, + meta: { + ...getChatChannelMeta(SLACK_CHANNEL), + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + agentPrompt: { + messageToolHints: ({ cfg, accountId }) => + isSlackInteractiveRepliesEnabled({ cfg, accountId }) + ? [ + "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", + "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", + ] + : [ + "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", + ], + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.slack"] }, + configSchema: buildChannelConfigSchema(SlackConfigSchema), + config: { + ...slackConfigBase, + isConfigured: (account) => isSlackPluginAccountConfigured(account), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: isSlackPluginAccountConfigured(account), + botTokenSource: account.botTokenSource, + appTokenSource: account.appTokenSource, + }), + ...slackConfigAccessors, + }, + setup: params.setup, + }; +} From ba79d903137e35c4089cd7e98610eb11731ebb0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 01:50:42 +0000 Subject: [PATCH 054/128] refactor(whatsapp): share plugin base config --- extensions/whatsapp/src/channel.setup.ts | 159 ++--------------- extensions/whatsapp/src/channel.ts | 168 +++--------------- extensions/whatsapp/src/shared.ts | 212 +++++++++++++++++++++++ 3 files changed, 249 insertions(+), 290 deletions(-) create mode 100644 extensions/whatsapp/src/shared.ts diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index df13d0b06f5..919a75c1a8c 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -1,150 +1,21 @@ -import { - buildAccountScopedDmSecurityPolicy, - buildChannelConfigSchema, - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, - DEFAULT_ACCOUNT_ID, - formatWhatsAppConfigAllowFromEntries, - getChatChannelMeta, - normalizeE164, - resolveWhatsAppConfigAllowFrom, - resolveWhatsAppConfigDefaultTo, - resolveWhatsAppGroupIntroHint, - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupToolPolicy, - WhatsAppConfigSchema, - type ChannelPlugin, -} from "../../../src/plugin-sdk-internal/whatsapp.js"; -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAccount, - type ResolvedWhatsAppAccount, -} from "./accounts.js"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/whatsapp"; +import { type ResolvedWhatsAppAccount } from "./accounts.js"; import { webAuthExists } from "./auth-store.js"; -import { whatsappSetupWizardProxy } from "./plugin-shared.js"; import { whatsappSetupAdapter } from "./setup-core.js"; +import { createWhatsAppPluginBase, createWhatsAppSetupWizardProxy } from "./shared.js"; + +async function loadWhatsAppChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const whatsappSetupWizardProxy = createWhatsAppSetupWizardProxy(async () => ({ + whatsappSetupWizard: (await loadWhatsAppChannelRuntime()).whatsappSetupWizard, +})); export const whatsappSetupPlugin: ChannelPlugin = { - id: "whatsapp", - meta: { - ...getChatChannelMeta("whatsapp"), - showConfigured: false, - quickstartAllowFrom: true, - forceAccountBinding: true, - preferSessionLookupForAnnounceTarget: true, - }, - setupWizard: whatsappSetupWizardProxy, - capabilities: { - chatTypes: ["direct", "group"], - polls: true, - reactions: true, - media: true, - }, - reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, - gatewayMethods: ["web.login.start", "web.login.wait"], - configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), - config: { - listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - const existing = accounts[accountKey] ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: { - ...accounts, - [accountKey]: { - ...existing, - enabled, - }, - }, - }, - }, - }; - }, - deleteAccount: ({ cfg, accountId }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - delete accounts[accountKey]; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: Object.keys(accounts).length ? accounts : undefined, - }, - }, - }; - }, - isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, - disabledReason: () => "disabled", + ...createWhatsAppPluginBase({ + setupWizard: whatsappSetupWizardProxy, + setup: whatsappSetupAdapter, isConfigured: async (account) => await webAuthExists(account.authDir), - unconfiguredReason: () => "not linked", - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.authDir), - linked: Boolean(account.authDir), - dmPolicy: account.dmPolicy, - allowFrom: account.allowFrom, - }), - resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => - buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "whatsapp", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.dmPolicy, - allowFrom: account.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw), - }), - collectWarnings: ({ account, cfg }) => { - const groupAllowlistConfigured = - Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.whatsapp !== undefined, - configuredGroupPolicy: account.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: groupAllowlistConfigured, - restrictSenders: { - surface: "WhatsApp groups", - openScope: "any member in allowed groups", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "WhatsApp groups", - routeAllowlistPath: "channels.whatsapp.groups", - routeScope: "group", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - }), - }); - }, - }, - setup: whatsappSetupAdapter, - groups: { - resolveRequireMention: resolveWhatsAppGroupRequireMention, - resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, - }, + }), }; diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 3f2c2e449dc..6fe1663e55f 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,45 +1,34 @@ -import { buildAccountScopedAllowlistConfigEditor } from "../../../src/plugin-sdk-internal/channel-config.js"; +import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat"; import { - buildChannelConfigSchema, - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, createActionGate, createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, - getChatChannelMeta, listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, - normalizeE164, formatWhatsAppConfigAllowFromEntries, readStringParam, resolveWhatsAppOutboundTarget, - resolveWhatsAppConfigAllowFrom, - resolveWhatsAppConfigDefaultTo, - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupIntroHint, - resolveWhatsAppGroupToolPolicy, resolveWhatsAppHeartbeatRecipients, resolveWhatsAppMentionStripRegexes, - WhatsAppConfigSchema, type ChannelMessageActionName, type ChannelPlugin, -} from "../../../src/plugin-sdk-internal/whatsapp.js"; +} from "openclaw/plugin-sdk/whatsapp"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../../src/whatsapp/normalize.js"; // WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAccount, - type ResolvedWhatsAppAccount, -} from "./accounts.js"; +import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; -import { whatsappSetupWizardProxy } from "./plugin-shared.js"; import { getWhatsAppRuntime } from "./runtime.js"; import { whatsappSetupAdapter } from "./setup-core.js"; +import { + createWhatsAppPluginBase, + createWhatsAppSetupWizardProxy, + WHATSAPP_CHANNEL, +} from "./shared.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; -const meta = getChatChannelMeta("whatsapp"); +async function loadWhatsAppChannelRuntime() { + return await import("./channel.runtime.js"); +} function normalizeWhatsAppPayloadText(text: string | undefined): string { return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); @@ -56,87 +45,21 @@ function parseWhatsAppExplicitTarget(raw: string) { }; } +const whatsappSetupWizardProxy = createWhatsAppSetupWizardProxy(async () => ({ + whatsappSetupWizard: (await loadWhatsAppChannelRuntime()).whatsappSetupWizard, +})); + export const whatsappPlugin: ChannelPlugin = { - id: "whatsapp", - meta: { - ...meta, - showConfigured: false, - quickstartAllowFrom: true, - forceAccountBinding: true, - preferSessionLookupForAnnounceTarget: true, - }, - setupWizard: whatsappSetupWizardProxy, + ...createWhatsAppPluginBase({ + setupWizard: whatsappSetupWizardProxy, + setup: whatsappSetupAdapter, + isConfigured: async (account) => + await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir), + }), agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()], pairing: { idLabel: "whatsappSenderId", }, - capabilities: { - chatTypes: ["direct", "group"], - polls: true, - reactions: true, - media: true, - }, - reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, - gatewayMethods: ["web.login.start", "web.login.wait"], - configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), - config: { - listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - const existing = accounts[accountKey] ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: { - ...accounts, - [accountKey]: { - ...existing, - enabled, - }, - }, - }, - }, - }; - }, - deleteAccount: ({ cfg, accountId }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - delete accounts[accountKey]; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: Object.keys(accounts).length ? accounts : undefined, - }, - }, - }; - }, - isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, - disabledReason: () => "disabled", - isConfigured: async (account) => - await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir), - unconfiguredReason: () => "not linked", - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.authDir), - linked: Boolean(account.authDir), - dmPolicy: account.dmPolicy, - allowFrom: account.allowFrom, - }), - resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), - }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => { @@ -157,53 +80,6 @@ export const whatsappPlugin: ChannelPlugin = { }), }), }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "whatsapp", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.dmPolicy, - allowFrom: account.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw), - }); - }, - collectWarnings: ({ account, cfg }) => { - const groupAllowlistConfigured = - Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.whatsapp !== undefined, - configuredGroupPolicy: account.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: groupAllowlistConfigured, - restrictSenders: { - surface: "WhatsApp groups", - openScope: "any member in allowed groups", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "WhatsApp groups", - routeAllowlistPath: "channels.whatsapp.groups", - routeScope: "group", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - }), - }); - }, - }, - setup: whatsappSetupAdapter, - groups: { - resolveRequireMention: resolveWhatsAppGroupRequireMention, - resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, - }, mentions: { stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx), }, @@ -256,7 +132,7 @@ export const whatsappPlugin: ChannelPlugin = { supportsAction: ({ action }) => action === "react", handleAction: async ({ action, params, cfg, accountId }) => { if (action !== "react") { - throw new Error(`Action ${action} is not supported for provider ${meta.id}.`); + throw new Error(`Action ${action} is not supported for provider ${WHATSAPP_CHANNEL}.`); } const messageId = readStringParam(params, "messageId", { required: true, diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts new file mode 100644 index 00000000000..3a8f7412e7e --- /dev/null +++ b/extensions/whatsapp/src/shared.ts @@ -0,0 +1,212 @@ +import { + buildAccountScopedDmSecurityPolicy, + buildChannelConfigSchema, + collectAllowlistProviderGroupPolicyWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, + DEFAULT_ACCOUNT_ID, + formatWhatsAppConfigAllowFromEntries, + getChatChannelMeta, + normalizeE164, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, + WhatsAppConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/whatsapp"; +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAccount, + type ResolvedWhatsAppAccount, +} from "./accounts.js"; + +export const WHATSAPP_CHANNEL = "whatsapp" as const; + +export function createWhatsAppSetupWizardProxy( + loadWizard: () => Promise<{ + whatsappSetupWizard: NonNullable["setupWizard"]>; + }>, +): NonNullable["setupWizard"]> { + return { + channel: WHATSAPP_CHANNEL, + status: { + configuredLabel: "linked", + unconfiguredLabel: "not linked", + configuredHint: "linked", + unconfiguredHint: "not linked", + configuredScore: 5, + unconfiguredScore: 4, + resolveConfigured: async ({ cfg }) => + await (await loadWizard()).whatsappSetupWizard.status.resolveConfigured({ cfg }), + resolveStatusLines: async ({ cfg, configured }) => + (await ( + await loadWizard() + ).whatsappSetupWizard.status.resolveStatusLines?.({ + cfg, + configured, + })) ?? [], + }, + resolveShouldPromptAccountIds: (params) => + (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, + credentials: [], + finalize: async (params) => await (await loadWizard()).whatsappSetupWizard.finalize!(params), + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + enabled: false, + }, + }, + }), + onAccountRecorded: (accountId, options) => { + options?.onWhatsAppAccountId?.(accountId); + }, + }; +} + +export function createWhatsAppPluginBase(params: { + setupWizard: NonNullable["setupWizard"]>; + setup: NonNullable["setup"]>; + isConfigured: NonNullable["config"]>["isConfigured"]; +}): Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "reload" + | "gatewayMethods" + | "configSchema" + | "config" + | "security" + | "setup" + | "groups" +> { + return { + id: WHATSAPP_CHANNEL, + meta: { + ...getChatChannelMeta(WHATSAPP_CHANNEL), + showConfigured: false, + quickstartAllowFrom: true, + forceAccountBinding: true, + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "group"], + polls: true, + reactions: true, + media: true, + }, + reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, + gatewayMethods: ["web.login.start", "web.login.wait"], + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), + config: { + listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + const existing = accounts[accountKey] ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: { + ...accounts, + [accountKey]: { + ...existing, + enabled, + }, + }, + }, + }, + }; + }, + deleteAccount: ({ cfg, accountId }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + delete accounts[accountKey]; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: Object.keys(accounts).length ? accounts : undefined, + }, + }, + }; + }, + isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, + disabledReason: () => "disabled", + isConfigured: params.isConfigured, + unconfiguredReason: () => "not linked", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.authDir), + linked: Boolean(account.authDir), + dmPolicy: account.dmPolicy, + allowFrom: account.allowFrom, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: WHATSAPP_CHANNEL, + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.dmPolicy, + allowFrom: account.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw), + }), + collectWarnings: ({ account, cfg }) => { + const groupAllowlistConfigured = + Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; + return collectAllowlistProviderGroupPolicyWarnings({ + cfg, + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + configuredGroupPolicy: account.groupPolicy, + collect: (groupPolicy) => + collectOpenGroupPolicyRouteAllowlistWarnings({ + groupPolicy, + routeAllowlistConfigured: groupAllowlistConfigured, + restrictSenders: { + surface: "WhatsApp groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "WhatsApp groups", + routeAllowlistPath: "channels.whatsapp.groups", + routeScope: "group", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + }), + }); + }, + }, + setup: params.setup, + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, + }; +} From 3cc1c7ba836d5c878552e22c792eab18e7d96e30 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:04:27 +0000 Subject: [PATCH 055/128] refactor(telegram): share plugin base config --- extensions/telegram/src/channel.setup.ts | 71 ++---------- extensions/telegram/src/channel.ts | 95 ++++------------ extensions/telegram/src/shared.ts | 137 +++++++++++++++++++++++ 3 files changed, 167 insertions(+), 136 deletions(-) create mode 100644 extensions/telegram/src/shared.ts diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index c349f5ec053..0ed71ae568c 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -1,69 +1,12 @@ -import { - buildChannelConfigSchema, - getChatChannelMeta, - TelegramConfigSchema, - type ChannelPlugin, -} from "../../../src/plugin-sdk-internal/telegram.js"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/telegram"; import { type ResolvedTelegramAccount } from "./accounts.js"; -import { - findTelegramTokenOwnerAccountId, - formatDuplicateTelegramTokenReason, - telegramConfigAccessors, - telegramConfigBase, -} from "./plugin-shared.js"; import type { TelegramProbe } from "./probe.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; +import { createTelegramPluginBase } from "./shared.js"; -export const telegramSetupPlugin: ChannelPlugin = { - id: "telegram", - meta: { - ...getChatChannelMeta("telegram"), - quickstartAllowFrom: true, - }, - setupWizard: telegramSetupWizard, - capabilities: { - chatTypes: ["direct", "group", "channel", "thread"], - reactions: true, - threads: true, - media: true, - polls: true, - nativeCommands: true, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.telegram"] }, - configSchema: buildChannelConfigSchema(TelegramConfigSchema), - config: { - ...telegramConfigBase, - isConfigured: (account, cfg) => { - if (!account.token?.trim()) { - return false; - } - return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - }, - unconfiguredReason: (account, cfg) => { - if (!account.token?.trim()) { - return "not configured"; - } - const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - if (!ownerAccountId) { - return "not configured"; - } - return formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }); - }, - describeAccount: (account, cfg) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: - Boolean(account.token?.trim()) && - !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), - tokenSource: account.tokenSource, - }), - ...telegramConfigAccessors, - }, - setup: telegramSetupAdapter, -}; +export const telegramSetupPlugin: ChannelPlugin = + createTelegramPluginBase({ + setupWizard: telegramSetupWizard, + setup: telegramSetupAdapter, + }); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index d73e63b0996..45cd93cd9e5 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,27 +1,18 @@ -import { parseTelegramTopicConversation } from "../../../src/acp/conversation-id.js"; -import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js"; -import { buildExecApprovalPendingReplyPayload } from "../../../src/infra/exec-approval-reply.js"; -import { - type OutboundSendDeps, - resolveOutboundSendDep, -} from "../../../src/infra/outbound/send-deps.js"; import { buildAccountScopedAllowlistConfigEditor, collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, createScopedDmSecurityResolver, -} from "../../../src/plugin-sdk-internal/channel-config.js"; +} from "openclaw/plugin-sdk/compat"; import { buildAgentSessionKey, resolveThreadSessionKeys, type RoutePeer, -} from "../../../src/plugin-sdk-internal/core.js"; +} from "openclaw/plugin-sdk/core"; import { - buildChannelConfigSchema, buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, - getChatChannelMeta, listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, PAIRING_APPROVED_MESSAGE, @@ -29,14 +20,22 @@ import { resolveConfiguredFromCredentialStatuses, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, - TelegramConfigSchema, - type ChannelPlugin, type ChannelMessageActionAdapter, + type ChannelPlugin, type OpenClawConfig, -} from "../../../src/plugin-sdk-internal/telegram.js"; +} from "openclaw/plugin-sdk/telegram"; +import { parseTelegramTopicConversation } from "../../../src/acp/conversation-id.js"; +import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js"; +import { buildExecApprovalPendingReplyPayload } from "../../../src/infra/exec-approval-reply.js"; +import { + type OutboundSendDeps, + resolveOutboundSendDep, +} from "../../../src/infra/outbound/send-deps.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; +import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, + resolveDefaultTelegramAccountId, resolveTelegramAccount, type ResolvedTelegramAccount, } from "./accounts.js"; @@ -51,17 +50,17 @@ 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 { - findTelegramTokenOwnerAccountId, - formatDuplicateTelegramTokenReason, - telegramConfigAccessors, - telegramConfigBase, -} from "./plugin-shared.js"; import { probeTelegram, type TelegramProbe } from "./probe.js"; import { getTelegramRuntime } from "./runtime.js"; import { sendTypingTelegram } from "./send.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; +import { + createTelegramPluginBase, + findTelegramTokenOwnerAccountId, + formatDuplicateTelegramTokenReason, + telegramConfigAccessors, +} from "./shared.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; @@ -69,8 +68,6 @@ type TelegramSendFn = ReturnType< typeof getTelegramRuntime >["channel"]["telegram"]["sendMessageTelegram"]; -const meta = getChatChannelMeta("telegram"); - type TelegramSendOptions = NonNullable[2]>; function buildTelegramSendOptions(params: { @@ -327,12 +324,10 @@ function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) { } export const telegramPlugin: ChannelPlugin = { - id: "telegram", - meta: { - ...meta, - quickstartAllowFrom: true, - }, - setupWizard: telegramSetupWizard, + ...createTelegramPluginBase({ + setupWizard: telegramSetupWizard, + setup: telegramSetupAdapter, + }), pairing: { idLabel: "telegramUserId", normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""), @@ -350,49 +345,6 @@ export const telegramPlugin: ChannelPlugin { - if (!account.token?.trim()) { - return false; - } - return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - }, - unconfiguredReason: (account, cfg) => { - if (!account.token?.trim()) { - return "not configured"; - } - const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - if (!ownerAccountId) { - return "not configured"; - } - return formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }); - }, - describeAccount: (account, cfg) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: - Boolean(account.token?.trim()) && - !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), - tokenSource: account.tokenSource, - }), - ...telegramConfigAccessors, - }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => @@ -548,7 +500,6 @@ export const telegramPlugin: ChannelPlugin listTelegramDirectoryGroupsFromConfig(params), }, actions: telegramMessageActions, - setup: telegramSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts new file mode 100644 index 00000000000..a1c7945520d --- /dev/null +++ b/extensions/telegram/src/shared.ts @@ -0,0 +1,137 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { + createScopedAccountConfigAccessors, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + normalizeAccountId, + TelegramConfigSchema, + type ChannelPlugin, + type OpenClawConfig, +} from "openclaw/plugin-sdk/telegram"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, + type ResolvedTelegramAccount, +} from "./accounts.js"; + +export const TELEGRAM_CHANNEL = "telegram" as const; + +export function findTelegramTokenOwnerAccountId(params: { + cfg: OpenClawConfig; + accountId: string; +}): string | null { + const normalizedAccountId = normalizeAccountId(params.accountId); + const tokenOwners = new Map(); + for (const id of listTelegramAccountIds(params.cfg)) { + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); + const token = (account.token ?? "").trim(); + if (!token) { + continue; + } + const ownerAccountId = tokenOwners.get(token); + if (!ownerAccountId) { + tokenOwners.set(token, account.accountId); + continue; + } + if (account.accountId === normalizedAccountId) { + return ownerAccountId; + } + } + return null; +} + +export function formatDuplicateTelegramTokenReason(params: { + accountId: string; + ownerAccountId: string; +}): string { + return ( + `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + + `account "${params.ownerAccountId}". Keep one owner account per bot token.` + ); +} + +export const telegramConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), + resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, +}); + +export const telegramConfigBase = createScopedChannelConfigBase({ + sectionKey: TELEGRAM_CHANNEL, + listAccountIds: listTelegramAccountIds, + resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultTelegramAccountId, + clearBaseFields: ["botToken", "tokenFile", "name"], +}); + +export function createTelegramPluginBase(params: { + setupWizard: NonNullable["setupWizard"]>; + setup: NonNullable["setup"]>; +}): Pick< + ChannelPlugin, + "id" | "meta" | "setupWizard" | "capabilities" | "reload" | "configSchema" | "config" | "setup" +> { + return { + id: TELEGRAM_CHANNEL, + meta: { + ...getChatChannelMeta(TELEGRAM_CHANNEL), + quickstartAllowFrom: true, + }, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "group", "channel", "thread"], + reactions: true, + threads: true, + media: true, + polls: true, + nativeCommands: true, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.telegram"] }, + configSchema: buildChannelConfigSchema(TelegramConfigSchema), + config: { + ...telegramConfigBase, + isConfigured: (account, cfg) => { + if (!account.token?.trim()) { + return false; + } + return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + }, + unconfiguredReason: (account, cfg) => { + if (!account.token?.trim()) { + return "not configured"; + } + const ownerAccountId = findTelegramTokenOwnerAccountId({ + cfg, + accountId: account.accountId, + }); + if (!ownerAccountId) { + return "not configured"; + } + return formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }); + }, + describeAccount: (account, cfg) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: + Boolean(account.token?.trim()) && + !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), + tokenSource: account.tokenSource, + }), + ...telegramConfigAccessors, + }, + setup: params.setup, + }; +} From a8853d23efe521aaa5cb09519fa9432fb691d20f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:07:28 +0000 Subject: [PATCH 056/128] refactor(signal): share plugin base config --- extensions/signal/src/channel.setup.ts | 95 +----------------- extensions/signal/src/channel.ts | 99 ++---------------- extensions/signal/src/shared.ts | 133 +++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 178 deletions(-) create mode 100644 extensions/signal/src/shared.ts diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index bc590cb235e..d633ff6a251 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -1,94 +1,9 @@ -import { - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderRestrictSendersWarnings, -} from "../../../src/plugin-sdk-internal/channel-config.js"; -import { - buildChannelConfigSchema, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - getChatChannelMeta, - normalizeE164, - setAccountEnabledInConfigSection, - SignalConfigSchema, - type ChannelPlugin, -} from "../../../src/plugin-sdk-internal/signal.js"; -import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, - type ResolvedSignalAccount, -} from "./accounts.js"; -import { signalConfigAccessors, signalSetupWizard } from "./plugin-shared.js"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/signal"; +import { type ResolvedSignalAccount } from "./accounts.js"; import { signalSetupAdapter } from "./setup-core.js"; +import { createSignalPluginBase, signalSetupWizard } from "./shared.js"; -export const signalSetupPlugin: ChannelPlugin = { - id: "signal", - meta: { - ...getChatChannelMeta("signal"), - }, +export const signalSetupPlugin: ChannelPlugin = createSignalPluginBase({ setupWizard: signalSetupWizard, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: true, - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.signal"] }, - configSchema: buildChannelConfigSchema(SignalConfigSchema), - config: { - listAccountIds: (cfg) => listSignalAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "signal", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "signal", - accountId, - clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - baseUrl: account.baseUrl, - }), - ...signalConfigAccessors, - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => - buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "signal", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), - }), - collectWarnings: ({ account, cfg }) => - collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.signal !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "Signal groups", - openScope: "any member", - groupPolicyPath: "channels.signal.groupPolicy", - groupAllowFromPath: "channels.signal.groupAllowFrom", - mentionGated: false, - }), - }, setup: signalSetupAdapter, -}; +}); diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index b0115d85a91..2b392bbacf2 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,31 +1,21 @@ -import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; -import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; -import { - buildAccountScopedAllowlistConfigEditor, - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderRestrictSendersWarnings, -} from "../../../src/plugin-sdk-internal/channel-config.js"; -import { buildAgentSessionKey, type RoutePeer } from "../../../src/plugin-sdk-internal/core.js"; +import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat"; +import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, - buildChannelConfigSchema, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - getChatChannelMeta, looksLikeSignalTargetId, - normalizeE164, normalizeSignalMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, - setAccountEnabledInConfigSection, - SignalConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, -} from "../../../src/plugin-sdk-internal/signal.js"; +} from "openclaw/plugin-sdk/signal"; +import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, @@ -39,10 +29,10 @@ import { resolveSignalRecipient, resolveSignalSender, } from "./identity.js"; -import { signalConfigAccessors, signalSetupWizard } from "./plugin-shared.js"; import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; import { signalSetupAdapter } from "./setup-core.js"; +import { createSignalPluginBase, signalConfigAccessors, signalSetupWizard } from "./shared.js"; const signalMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [], @@ -292,11 +282,10 @@ async function sendFormattedSignalMedia(ctx: { } export const signalPlugin: ChannelPlugin = { - id: "signal", - meta: { - ...getChatChannelMeta("signal"), - }, - setupWizard: signalSetupWizard, + ...createSignalPluginBase({ + setupWizard: signalSetupWizard, + setup: signalSetupAdapter, + }), pairing: { idLabel: "signalNumber", normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""), @@ -304,46 +293,7 @@ export const signalPlugin: ChannelPlugin = { await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE); }, }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: true, - }, actions: signalMessageActions, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.signal"] }, - configSchema: buildChannelConfigSchema(SignalConfigSchema), - config: { - listAccountIds: (cfg) => listSignalAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "signal", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "signal", - accountId, - clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - baseUrl: account.baseUrl, - }), - ...signalConfigAccessors, - }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => { @@ -365,32 +315,6 @@ export const signalPlugin: ChannelPlugin = { }), }), }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "signal", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), - }); - }, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.signal !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "Signal groups", - openScope: "any member", - groupPolicyPath: "channels.signal.groupPolicy", - groupAllowFromPath: "channels.signal.groupAllowFrom", - mentionGated: false, - }); - }, - }, messaging: { normalizeTarget: normalizeSignalMessagingTarget, parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw), @@ -401,7 +325,6 @@ export const signalPlugin: ChannelPlugin = { hint: "", }, }, - setup: signalSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit), diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts new file mode 100644 index 00000000000..7c914f7ddf2 --- /dev/null +++ b/extensions/signal/src/shared.ts @@ -0,0 +1,133 @@ +import { + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, + createScopedAccountConfigAccessors, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + getChatChannelMeta, + normalizeE164, + setAccountEnabledInConfigSection, + SignalConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/signal"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, + type ResolvedSignalAccount, +} from "./accounts.js"; +import { createSignalSetupWizardProxy } from "./setup-core.js"; + +export const SIGNAL_CHANNEL = "signal" as const; + +async function loadSignalChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ + signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, +})); + +export const signalConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) + .filter(Boolean), + resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, +}); + +export function createSignalPluginBase(params: { + setupWizard?: NonNullable["setupWizard"]>; + setup: NonNullable["setup"]>; +}): Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "streaming" + | "reload" + | "configSchema" + | "config" + | "security" + | "setup" +> { + return { + id: SIGNAL_CHANNEL, + meta: { + ...getChatChannelMeta(SIGNAL_CHANNEL), + }, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.signal"] }, + configSchema: buildChannelConfigSchema(SignalConfigSchema), + config: { + listAccountIds: (cfg) => listSignalAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: SIGNAL_CHANNEL, + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: SIGNAL_CHANNEL, + accountId, + clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + }), + ...signalConfigAccessors, + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: SIGNAL_CHANNEL, + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), + }), + collectWarnings: ({ account, cfg }) => + collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.signal !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "Signal groups", + openScope: "any member", + groupPolicyPath: "channels.signal.groupPolicy", + groupAllowFromPath: "channels.signal.groupAllowFrom", + mentionGated: false, + }), + }, + setup: params.setup, + }; +} From 31a82259516c09d31340d66e46858750b588ea24 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:18:22 +0000 Subject: [PATCH 057/128] refactor(imessage): share plugin base config --- extensions/imessage/src/channel.setup.ts | 99 ++----------------- extensions/imessage/src/channel.ts | 103 ++------------------ extensions/imessage/src/shared.ts | 119 +++++++++++++++++++++++ 3 files changed, 137 insertions(+), 184 deletions(-) create mode 100644 extensions/imessage/src/shared.ts diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts index 16d758931c2..5587914a0ce 100644 --- a/extensions/imessage/src/channel.setup.ts +++ b/extensions/imessage/src/channel.setup.ts @@ -1,94 +1,11 @@ -import { - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderRestrictSendersWarnings, -} from "../../../src/plugin-sdk-internal/channel-config.js"; -import { - buildChannelConfigSchema, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - formatTrimmedAllowFromEntries, - getChatChannelMeta, - IMessageConfigSchema, - resolveIMessageConfigAllowFrom, - resolveIMessageConfigDefaultTo, - setAccountEnabledInConfigSection, - type ChannelPlugin, -} from "../../../src/plugin-sdk-internal/imessage.js"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, - type ResolvedIMessageAccount, -} from "./accounts.js"; -import { imessageSetupWizard } from "./plugin-shared.js"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/imessage"; +import { type ResolvedIMessageAccount } from "./accounts.js"; import { imessageSetupAdapter } from "./setup-core.js"; +import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; -export const imessageSetupPlugin: ChannelPlugin = { - id: "imessage", - meta: { - ...getChatChannelMeta("imessage"), - aliases: ["imsg"], - showConfigured: false, +export const imessageSetupPlugin: ChannelPlugin = createIMessagePluginBase( + { + setupWizard: imessageSetupWizard, + setup: imessageSetupAdapter, }, - setupWizard: imessageSetupWizard, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - }, - reload: { configPrefixes: ["channels.imessage"] }, - configSchema: buildChannelConfigSchema(IMessageConfigSchema), - config: { - listAccountIds: (cfg) => listIMessageAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - }), - resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => - buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "imessage", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - }), - collectWarnings: ({ account, cfg }) => - collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.imessage !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "iMessage groups", - openScope: "any member", - groupPolicyPath: "channels.imessage.groupPolicy", - groupAllowFromPath: "channels.imessage.groupAllowFrom", - mentionGated: false, - }), - }, - setup: imessageSetupAdapter, -}; +); diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 18ae103281a..95cac7d1123 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,43 +1,25 @@ -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat"; +import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core"; import { - buildAccountScopedAllowlistConfigEditor, - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderRestrictSendersWarnings, -} from "../../../src/plugin-sdk-internal/channel-config.js"; -import { buildAgentSessionKey, type RoutePeer } from "../../../src/plugin-sdk-internal/core.js"; -import { - buildChannelConfigSchema, collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, formatTrimmedAllowFromEntries, - getChatChannelMeta, - IMessageConfigSchema, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, - resolveIMessageConfigAllowFrom, - resolveIMessageConfigDefaultTo, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, - setAccountEnabledInConfigSection, type ChannelPlugin, -} from "../../../src/plugin-sdk-internal/imessage.js"; +} from "openclaw/plugin-sdk/imessage"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, - type ResolvedIMessageAccount, -} from "./accounts.js"; -import { imessageSetupWizard } from "./plugin-shared.js"; +import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { getIMessageRuntime } from "./runtime.js"; import { imessageSetupAdapter } from "./setup-core.js"; +import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; -const meta = getChatChannelMeta("imessage"); - type IMessageSendFn = ReturnType< typeof getIMessageRuntime >["channel"]["imessage"]["sendMessageIMessage"]; @@ -150,55 +132,16 @@ function resolveIMessageOutboundSessionRoute(params: { } export const imessagePlugin: ChannelPlugin = { - id: "imessage", - meta: { - ...meta, - aliases: ["imsg"], - showConfigured: false, - }, - setupWizard: imessageSetupWizard, + ...createIMessagePluginBase({ + setupWizard: imessageSetupWizard, + setup: imessageSetupAdapter, + }), pairing: { idLabel: "imessageSenderId", notifyApproval: async ({ id }) => { await getIMessageRuntime().channel.imessage.sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE); }, }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - }, - reload: { configPrefixes: ["channels.imessage"] }, - configSchema: buildChannelConfigSchema(IMessageConfigSchema), - config: { - listAccountIds: (cfg) => listIMessageAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - }), - resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), - }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => { @@ -219,31 +162,6 @@ export const imessagePlugin: ChannelPlugin = { }), }), }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "imessage", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - }); - }, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.imessage !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "iMessage groups", - openScope: "any member", - groupPolicyPath: "channels.imessage.groupPolicy", - groupAllowFromPath: "channels.imessage.groupAllowFrom", - mentionGated: false, - }); - }, - }, groups: { resolveRequireMention: resolveIMessageGroupRequireMention, resolveToolPolicy: resolveIMessageGroupToolPolicy, @@ -256,7 +174,6 @@ export const imessagePlugin: ChannelPlugin = { hint: "", }, }, - setup: imessageSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit), diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts new file mode 100644 index 00000000000..c4c62f20494 --- /dev/null +++ b/extensions/imessage/src/shared.ts @@ -0,0 +1,119 @@ +import { + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatTrimmedAllowFromEntries, + getChatChannelMeta, + IMessageConfigSchema, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, + setAccountEnabledInConfigSection, + type ChannelPlugin, +} from "openclaw/plugin-sdk/imessage"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, + type ResolvedIMessageAccount, +} from "./accounts.js"; +import { createIMessageSetupWizardProxy } from "./setup-core.js"; + +export const IMESSAGE_CHANNEL = "imessage" as const; + +async function loadIMessageChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ + imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, +})); + +export function createIMessagePluginBase(params: { + setupWizard?: NonNullable["setupWizard"]>; + setup: NonNullable["setup"]>; +}): Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "reload" + | "configSchema" + | "config" + | "security" + | "setup" +> { + return { + id: IMESSAGE_CHANNEL, + meta: { + ...getChatChannelMeta(IMESSAGE_CHANNEL), + aliases: ["imsg"], + showConfigured: false, + }, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + }, + reload: { configPrefixes: ["channels.imessage"] }, + configSchema: buildChannelConfigSchema(IMessageConfigSchema), + config: { + listAccountIds: (cfg) => listIMessageAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: IMESSAGE_CHANNEL, + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: IMESSAGE_CHANNEL, + accountId, + clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: IMESSAGE_CHANNEL, + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + }), + collectWarnings: ({ account, cfg }) => + collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.imessage !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "iMessage groups", + openScope: "any member", + groupPolicyPath: "channels.imessage.groupPolicy", + groupAllowFromPath: "channels.imessage.groupAllowFrom", + mentionGated: false, + }), + }, + setup: params.setup, + }; +} From c3571d982dd12457dd4b3ee9dbe7f081270338e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:19:39 +0000 Subject: [PATCH 058/128] refactor(nextcloud-talk): share setup allowlist prompt --- extensions/nextcloud-talk/src/setup-core.ts | 8 +- .../nextcloud-talk/src/setup-surface.ts | 97 +------------------ 2 files changed, 8 insertions(+), 97 deletions(-) diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 1d45a392fd1..212d81380f1 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -115,7 +115,7 @@ export function clearNextcloudTalkAccountFields( } as CoreConfig; } -async function promptNextcloudTalkAllowFrom(params: { +export async function promptNextcloudTalkAllowFrom(params: { cfg: CoreConfig; prompter: WizardPrompter; accountId: string; @@ -127,7 +127,7 @@ async function promptNextcloudTalkAllowFrom(params: { "1) Check the Nextcloud admin panel for user IDs", "2) Or look at the webhook payload logs when someone messages", "3) User IDs are typically lowercase usernames in Nextcloud", - `Docs: ${formatDocsLink("/channels/nextcloud-talk", "nextcloud-talk")}`, + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, ].join("\n"), "Nextcloud Talk user id", ); @@ -158,7 +158,7 @@ async function promptNextcloudTalkAllowFrom(params: { }); } -async function promptNextcloudTalkAllowFromForAccount(params: { +export async function promptNextcloudTalkAllowFromForAccount(params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string; @@ -174,7 +174,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: { }); } -const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { +export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { label: "Nextcloud Talk", channel, policyKey: "channels.nextcloud-talk.dmPolicy", diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index da839359ff2..46561f5b274 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -1,111 +1,22 @@ -import { - mergeAllowFromEntries, - resolveSetupAccountId, - setSetupChannelEnabled, - setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; +import { setSetupChannelEnabled } from "../../../src/channels/plugins/setup-wizard-helpers.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.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"; import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { - listNextcloudTalkAccountIds, - resolveDefaultNextcloudTalkAccountId, - resolveNextcloudTalkAccount, -} from "./accounts.js"; +import { listNextcloudTalkAccountIds, resolveNextcloudTalkAccount } from "./accounts.js"; import { clearNextcloudTalkAccountFields, + nextcloudTalkDmPolicy, nextcloudTalkSetupAdapter, normalizeNextcloudTalkBaseUrl, setNextcloudTalkAccountConfig, validateNextcloudTalkBaseUrl, } from "./setup-core.js"; -import type { CoreConfig, DmPolicy } from "./types.js"; +import type { CoreConfig } from "./types.js"; const channel = "nextcloud-talk" as const; const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials"; -function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as CoreConfig; -} - -async function promptNextcloudTalkAllowFrom(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId: string; -}): Promise { - const resolved = resolveNextcloudTalkAccount({ cfg: params.cfg, accountId: params.accountId }); - const existingAllowFrom = resolved.config.allowFrom ?? []; - await params.prompter.note( - [ - "1) Check the Nextcloud admin panel for user IDs", - "2) Or look at the webhook payload logs when someone messages", - "3) User IDs are typically lowercase usernames in Nextcloud", - `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, - ].join("\n"), - "Nextcloud Talk user id", - ); - - let resolvedIds: string[] = []; - while (resolvedIds.length === 0) { - const entry = await params.prompter.text({ - message: "Nextcloud Talk allowFrom (user id)", - placeholder: "username", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - resolvedIds = String(entry) - .split(/[\n,;]+/g) - .map((value) => value.trim().toLowerCase()) - .filter(Boolean); - if (resolvedIds.length === 0) { - await params.prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk"); - } - } - - return setNextcloudTalkAccountConfig(params.cfg, params.accountId, { - dmPolicy: "allowlist", - allowFrom: mergeAllowFromEntries( - existingAllowFrom.map((value) => String(value).trim().toLowerCase()), - resolvedIds, - ), - }); -} - -async function promptNextcloudTalkAllowFromForAccount(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = resolveSetupAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), - }); - return await promptNextcloudTalkAllowFrom({ - cfg: params.cfg as CoreConfig, - prompter: params.prompter, - accountId, - }); -} - -const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { - label: "Nextcloud Talk", - channel, - policyKey: "channels.nextcloud-talk.dmPolicy", - allowFromKey: "channels.nextcloud-talk.allowFrom", - getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), - promptAllowFrom: promptNextcloudTalkAllowFromForAccount, -}; - export const nextcloudTalkSetupWizard: ChannelSetupWizard = { channel, stepOrder: "text-first", From 7758873d7e0428b3d0b13568adff4fa2a1da200c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:22:54 +0000 Subject: [PATCH 059/128] refactor(slack): share setup wizard base --- extensions/slack/src/setup-core.ts | 131 ++++++------ extensions/slack/src/setup-surface.ts | 278 ++++---------------------- 2 files changed, 113 insertions(+), 296 deletions(-) diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index a0f068b3e81..80369d417a7 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -110,9 +110,30 @@ export const slackSetupAdapter: ChannelSetupAdapter = { }, }; -export function createSlackSetupWizardProxy( - loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, -) { +type SlackAllowFromResolverParams = { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { botToken?: string }; + entries: string[]; +}; + +type SlackGroupAllowlistResolverParams = SlackAllowFromResolverParams & { + prompter: { note: (message: string, title?: string) => Promise }; +}; + +type SlackSetupWizardHandlers = { + promptAllowFrom: (params: { + cfg: OpenClawConfig; + prompter: import("../../../src/plugin-sdk-internal/setup.js").WizardPrompter; + accountId?: string; + }) => Promise; + resolveAllowFromEntries: ( + params: SlackAllowFromResolverParams, + ) => Promise; + resolveGroupAllowlist: (params: SlackGroupAllowlistResolverParams) => Promise; +}; + +export function createSlackSetupWizardBase(handlers: SlackSetupWizardHandlers): ChannelSetupWizard { const slackDmPolicy: ChannelSetupDmPolicy = { label: "Slack", channel, @@ -126,13 +147,7 @@ export function createSlackSetupWizardProxy( channel, dmPolicy: policy, }), - promptAllowFrom: async ({ cfg, prompter, accountId }) => { - const wizard = (await loadWizard()).slackSetupWizard; - if (!wizard.dmPolicy?.promptAllowFrom) { - return cfg; - } - return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); - }, + promptAllowFrom: handlers.promptAllowFrom, }; return { @@ -273,28 +288,7 @@ export function createSlackSetupWizardProxy( idPattern: /^[A-Z][A-Z0-9]+$/i, normalizeId: (id) => id.toUpperCase(), }), - resolveEntries: async ({ - cfg, - accountId, - credentialValues, - entries, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { botToken?: string }; - entries: string[]; - }) => { - const wizard = (await loadWizard()).slackSetupWizard; - if (!wizard.allowFrom) { - return entries.map((input) => ({ input, resolved: false, id: null })); - } - return await wizard.allowFrom.resolveEntries({ - cfg, - accountId, - credentialValues, - entries, - }); - }, + resolveEntries: handlers.resolveAllowFromEntries, apply: ({ cfg, accountId, @@ -337,44 +331,22 @@ export function createSlackSetupWizardProxy( accountId, groupPolicy: policy, }), - resolveAllowlist: async ({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { botToken?: string }; - entries: string[]; - prompter: { note: (message: string, title?: string) => Promise }; - }) => { + resolveAllowlist: async (params: SlackGroupAllowlistResolverParams) => { try { - const wizard = (await loadWizard()).slackSetupWizard; - if (!wizard.groupAccess?.resolveAllowlist) { - return entries; - } - return await wizard.groupAccess.resolveAllowlist({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }); + return await handlers.resolveGroupAllowlist(params); } catch (error) { await noteChannelLookupFailure({ - prompter, + prompter: params.prompter, label: "Slack channels", error, }); await noteChannelLookupSummary({ - prompter, + prompter: params.prompter, label: "Slack channels", resolvedSections: [], - unresolved: entries, + unresolved: params.entries, }); - return entries; + return params.entries; } }, applyAllowlist: ({ @@ -390,3 +362,42 @@ export function createSlackSetupWizardProxy( disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } + +export function createSlackSetupWizardProxy( + loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, +) { + return createSlackSetupWizardBase({ + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, + resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, + resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.groupAccess?.resolveAllowlist) { + return entries; + } + return (await wizard.groupAccess.resolveAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + })) as string[]; + }, + }); +} diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index de7dc06e40e..8f5024276ca 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,50 +1,22 @@ import { - DEFAULT_ACCOUNT_ID, formatDocsLink, - hasConfiguredSecretInput, noteChannelLookupFailure, noteChannelLookupSummary, - normalizeAccountId, type OpenClawConfig, parseMentionOrPrefixedId, - patchChannelConfigForAccount, promptLegacyChannelAllowFrom, resolveSetupAccountId, - setAccountGroupPolicyForChannel, - setLegacyChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, type WizardPrompter, } from "../../../src/plugin-sdk-internal/setup.js"; import type { - ChannelSetupDmPolicy, ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, } from "../../../src/plugin-sdk-internal/setup.js"; -import { inspectSlackAccount } from "./account-inspect.js"; -import { - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, - type ResolvedSlackAccount, -} from "./accounts.js"; +import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js"; import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; -import { slackSetupAdapter } from "./setup-core.js"; -import { - buildSlackSetupLines, - isSlackSetupAccountConfigured, - setSlackChannelAllowlist, - SLACK_CHANNEL as channel, -} from "./shared.js"; - -function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig { - return patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { enabled: true }, - }); -} +import { createSlackSetupWizardBase } from "./setup-core.js"; +import { SLACK_CHANNEL as channel } from "./shared.js"; async function resolveSlackAllowFromEntries(params: { token?: string; @@ -117,211 +89,45 @@ async function promptSlackAllowFrom(params: { }); } -const slackDmPolicy: ChannelSetupDmPolicy = { - label: "Slack", - channel, - policyKey: "channels.slack.dmPolicy", - allowFromKey: "channels.slack.allowFrom", - getCurrent: (cfg) => - cfg.channels?.slack?.dmPolicy ?? cfg.channels?.slack?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => - setLegacyChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), +export const slackSetupWizard: ChannelSetupWizard = createSlackSetupWizardBase({ promptAllowFrom: promptSlackAllowFrom, -}; - -export const slackSetupWizard: ChannelSetupWizard = { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs tokens", - configuredHint: "configured", - unconfiguredHint: "needs tokens", - configuredScore: 2, - unconfiguredScore: 1, - resolveConfigured: ({ cfg }) => - listSlackAccountIds(cfg).some((accountId) => { - const account = inspectSlackAccount({ cfg, accountId }); - return account.configured; - }), - }, - introNote: { - title: "Slack socket mode tokens", - lines: buildSlackSetupLines(), - shouldShow: ({ cfg, accountId }) => - !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), - }, - envShortcut: { - prompt: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", - preferredEnvVar: "SLACK_BOT_TOKEN", - isAvailable: ({ cfg, accountId }) => - accountId === DEFAULT_ACCOUNT_ID && - Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && - Boolean(process.env.SLACK_APP_TOKEN?.trim()) && - !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), - apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), - }, - credentials: [ - { - inputKey: "botToken", - providerHint: "slack-bot", - credentialLabel: "Slack bot token", - preferredEnvVar: "SLACK_BOT_TOKEN", - envPrompt: "SLACK_BOT_TOKEN detected. Use env var?", - keepPrompt: "Slack bot token already configured. Keep it?", - inputPrompt: "Enter Slack bot token (xoxb-...)", - allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - const resolved = resolveSlackAccount({ cfg, accountId }); - return { - accountConfigured: - Boolean(resolved.botToken) || hasConfiguredSecretInput(resolved.config.botToken), - hasConfiguredValue: hasConfiguredSecretInput(resolved.config.botToken), - resolvedValue: resolved.botToken?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_BOT_TOKEN?.trim() : undefined, - }; - }, - applyUseEnv: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), - applySet: ({ cfg, accountId, value }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { - enabled: true, - botToken: value, - }, - }), - }, - { - inputKey: "appToken", - providerHint: "slack-app", - credentialLabel: "Slack app token", - preferredEnvVar: "SLACK_APP_TOKEN", - envPrompt: "SLACK_APP_TOKEN detected. Use env var?", - keepPrompt: "Slack app token already configured. Keep it?", - inputPrompt: "Enter Slack app token (xapp-...)", - allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - const resolved = resolveSlackAccount({ cfg, accountId }); - return { - accountConfigured: - Boolean(resolved.appToken) || hasConfiguredSecretInput(resolved.config.appToken), - hasConfiguredValue: hasConfiguredSecretInput(resolved.config.appToken), - resolvedValue: resolved.appToken?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_APP_TOKEN?.trim() : undefined, - }; - }, - applyUseEnv: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), - applySet: ({ cfg, accountId, value }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { - enabled: true, - appToken: value, - }, - }), - }, - ], - dmPolicy: slackDmPolicy, - allowFrom: { - helpTitle: "Slack allowlist", - helpLines: [ - "Allowlist Slack DMs by username (we resolve to user ids).", - "Examples:", - "- U12345678", - "- @alice", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/slack", "slack")}`, - ], - credentialInputKey: "botToken", - message: "Slack allowFrom (usernames or ids)", - placeholder: "@alice, U12345678", - invalidWithoutCredentialNote: "Slack token missing; use user ids (or mention form) only.", - parseId: (value) => - parseMentionOrPrefixedId({ - value, - mentionPattern: /^<@([A-Z0-9]+)>$/i, - prefixPattern: /^(slack:|user:)/i, - idPattern: /^[A-Z][A-Z0-9]+$/i, - normalizeId: (id) => id.toUpperCase(), - }), - resolveEntries: async ({ credentialValues, entries }) => - await resolveSlackAllowFromEntries({ - token: credentialValues.botToken, - entries, - }), - apply: ({ cfg, accountId, allowFrom }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), - }, - groupAccess: { - label: "Slack channels", - placeholder: "#general, #private, C123", - currentPolicy: ({ cfg, accountId }) => - resolveSlackAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", - currentEntries: ({ cfg, accountId }) => - Object.entries(resolveSlackAccount({ cfg, accountId }).config.channels ?? {}) - .filter(([, value]) => value?.allow !== false && value?.enabled !== false) - .map(([key]) => key), - updatePrompt: ({ cfg, accountId }) => - Boolean(resolveSlackAccount({ cfg, accountId }).config.channels), - setPolicy: ({ cfg, accountId, policy }) => - setAccountGroupPolicyForChannel({ - cfg, - channel, - accountId, - groupPolicy: policy, - }), - resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { - let keys = entries; - const accountWithTokens = resolveSlackAccount({ - cfg, - accountId, - }); - const activeBotToken = accountWithTokens.botToken || credentialValues.botToken || ""; - if (activeBotToken && entries.length > 0) { - try { - const resolved = await resolveSlackChannelAllowlist({ - token: activeBotToken, - entries, - }); - const resolvedKeys = resolved - .filter((entry) => entry.resolved && entry.id) - .map((entry) => entry.id as string); - const unresolved = resolved - .filter((entry) => !entry.resolved) - .map((entry) => entry.input); - keys = [...resolvedKeys, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - await noteChannelLookupSummary({ - prompter, - label: "Slack channels", - resolvedSections: [{ title: "Resolved", values: resolvedKeys }], - unresolved, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: "Slack channels", - error, - }); - } + resolveAllowFromEntries: async ({ credentialValues, entries }) => + await resolveSlackAllowFromEntries({ + token: credentialValues.botToken, + entries, + }), + resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + let keys = entries; + const accountWithTokens = resolveSlackAccount({ + cfg, + accountId, + }); + const activeBotToken = accountWithTokens.botToken || credentialValues.botToken || ""; + if (activeBotToken && entries.length > 0) { + try { + const resolved = await resolveSlackChannelAllowlist({ + token: activeBotToken, + entries, + }); + const resolvedKeys = resolved + .filter((entry) => entry.resolved && entry.id) + .map((entry) => entry.id as string); + const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); + keys = [...resolvedKeys, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + await noteChannelLookupSummary({ + prompter, + label: "Slack channels", + resolvedSections: [{ title: "Resolved", values: resolvedKeys }], + unresolved, + }); + } catch (error) { + await noteChannelLookupFailure({ + prompter, + label: "Slack channels", + error, + }); } - return keys; - }, - applyAllowlist: ({ cfg, accountId, resolved }) => - setSlackChannelAllowlist(cfg, accountId, resolved as string[]), + } + return keys; }, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; +}); From 6d6e08b147c92da120fa8124c96323d0c238014a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:29:45 +0000 Subject: [PATCH 060/128] refactor(signal): share setup wizard base --- extensions/signal/src/setup-core.ts | 74 +++++++---- extensions/signal/src/setup-surface.ts | 170 +++++-------------------- 2 files changed, 78 insertions(+), 166 deletions(-) diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 7e78fbf64a5..1e2ea595756 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,8 +1,6 @@ import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - formatCliCommand, - formatDocsLink, migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeE164, @@ -18,6 +16,7 @@ import type { ChannelSetupDmPolicy, ChannelSetupWizard, } from "../../../src/plugin-sdk-internal/setup.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, @@ -28,7 +27,7 @@ const channel = "signal" as const; const MIN_E164_DIGITS = 5; const MAX_E164_DIGITS = 15; const DIGITS_ONLY = /^\d+$/; -const INVALID_SIGNAL_ACCOUNT_ERROR = +export const INVALID_SIGNAL_ACCOUNT_ERROR = "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; export function normalizeSignalAccountInput(value: string | null | undefined): string | null { @@ -87,7 +86,7 @@ function buildSignalSetupPatch(input: { }; } -async function promptSignalAllowFrom(params: { +export async function promptSignalAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string; @@ -184,26 +183,37 @@ export const signalSetupAdapter: ChannelSetupAdapter = { }, }; -export function createSignalSetupWizardProxy( - loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, -) { +type SignalSetupWizardHandlers = { + resolveStatusLines: NonNullable["resolveStatusLines"]; + resolveSelectionHint: NonNullable["resolveSelectionHint"]; + resolveQuickstartScore: NonNullable["resolveQuickstartScore"]; + prepare?: ChannelSetupWizard["prepare"]; + shouldPromptCliPath: NonNullable< + NonNullable[number]["shouldPrompt"] + >; +}; + +export function createSignalSetupWizardBase( + handlers: SignalSetupWizardHandlers, +): ChannelSetupWizard { + const setupChannel = "signal" as const; const signalDmPolicy: ChannelSetupDmPolicy = { label: "Signal", - channel, + channel: setupChannel, policyKey: "channels.signal.dmPolicy", allowFromKey: "channels.signal.allowFrom", getCurrent: (cfg: OpenClawConfig) => cfg.channels?.signal?.dmPolicy ?? "pairing", setPolicy: (cfg: OpenClawConfig, policy) => setChannelDmPolicyWithAllowFrom({ cfg, - channel, + channel: setupChannel, dmPolicy: policy, }), promptAllowFrom: promptSignalAllowFrom, }; return { - channel, + channel: setupChannel, status: { configuredLabel: "configured", unconfiguredLabel: "needs setup", @@ -215,14 +225,11 @@ export function createSignalSetupWizardProxy( listSignalAccountIds(cfg).some( (accountId) => resolveSignalAccount({ cfg, accountId }).configured, ), - resolveStatusLines: async (params) => - (await loadWizard()).signalSetupWizard.status.resolveStatusLines?.(params) ?? [], - resolveSelectionHint: async (params) => - await (await loadWizard()).signalSetupWizard.status.resolveSelectionHint?.(params), - resolveQuickstartScore: async (params) => - await (await loadWizard()).signalSetupWizard.status.resolveQuickstartScore?.(params), + resolveStatusLines: handlers.resolveStatusLines, + resolveSelectionHint: handlers.resolveSelectionHint, + resolveQuickstartScore: handlers.resolveQuickstartScore, }, - prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params), + prepare: handlers.prepare, credentials: [], textInputs: [ { @@ -236,12 +243,7 @@ export function createSignalSetupWizardProxy( (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? resolveSignalAccount({ cfg, accountId }).config.cliPath ?? "signal-cli", - shouldPrompt: async (params) => { - const input = (await loadWizard()).signalSetupWizard.textInputs?.find( - (entry) => entry.inputKey === "cliPath", - ); - return (await input?.shouldPrompt?.(params)) ?? false; - }, + shouldPrompt: handlers.shouldPromptCliPath, confirmCurrentValue: false, applyCurrentValue: true, helpTitle: "Signal", @@ -266,11 +268,31 @@ export function createSignalSetupWizardProxy( lines: [ 'Link device with: signal-cli link -n "OpenClaw"', "Scan QR in Signal -> Linked Devices", - `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, - `Docs: ${formatDocsLink("/signal", "signal")}`, + `Then run: openclaw gateway call channels.status --params '{"probe":true}'`, + "Docs: https://docs.openclaw.ai/signal", ], }, dmPolicy: signalDmPolicy, - disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, setupChannel, false), } satisfies ChannelSetupWizard; } + +export function createSignalSetupWizardProxy( + loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, +) { + return createSignalSetupWizardBase({ + resolveStatusLines: async (params) => + (await loadWizard()).signalSetupWizard.status.resolveStatusLines?.(params) ?? [], + resolveSelectionHint: async (params) => + await (await loadWizard()).signalSetupWizard.status.resolveSelectionHint?.(params), + resolveQuickstartScore: async (params) => + await (await loadWizard()).signalSetupWizard.status.resolveQuickstartScore?.(params), + prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params), + shouldPromptCliPath: async (params) => { + const input = (await loadWizard()).signalSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }, + }); +} diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 5c40ba0788e..e3ac6f7e42a 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,107 +1,34 @@ import { - DEFAULT_ACCOUNT_ID, detectBinary, - formatCliCommand, - formatDocsLink, installSignalCli, type OpenClawConfig, - parseSetupEntriesAllowingWildcard, - promptParsedAllowFromForScopedChannel, - setChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, - type WizardPrompter, -} from "../../../src/plugin-sdk-internal/setup.js"; -import type { - ChannelSetupDmPolicy, - ChannelSetupWizard, } from "../../../src/plugin-sdk-internal/setup.js"; +import type { ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; +import { resolveSignalAccount } from "./accounts.js"; import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, -} from "./accounts.js"; -import { + createSignalSetupWizardBase, + INVALID_SIGNAL_ACCOUNT_ERROR, normalizeSignalAccountInput, - parseSignalAllowFromEntries, + promptSignalAllowFrom, signalSetupAdapter, } from "./setup-core.js"; -const channel = "signal" as const; -const INVALID_SIGNAL_ACCOUNT_ERROR = - "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; - -async function promptSignalAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - return promptParsedAllowFromForScopedChannel({ - cfg: params.cfg, - channel, - accountId: params.accountId, - defaultAccountId: resolveDefaultSignalAccountId(params.cfg), - prompter: params.prompter, - noteTitle: "Signal allowlist", - noteLines: [ - "Allowlist Signal DMs by sender id.", - "Examples:", - "- +15555550123", - "- uuid:123e4567-e89b-12d3-a456-426614174000", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/signal", "signal")}`, - ], - message: "Signal allowFrom (E.164 or uuid)", - placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000", - parseEntries: parseSignalAllowFromEntries, - getExistingAllowFrom: ({ cfg, accountId }) => - resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? [], - }); -} - -const signalDmPolicy: ChannelSetupDmPolicy = { - label: "Signal", - channel, - policyKey: "channels.signal.dmPolicy", - allowFromKey: "channels.signal.allowFrom", - getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptSignalAllowFrom, -}; - -export const signalSetupWizard: ChannelSetupWizard = { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs setup", - configuredHint: "signal-cli found", - unconfiguredHint: "signal-cli missing", - configuredScore: 1, - unconfiguredScore: 0, - resolveConfigured: ({ cfg }) => - listSignalAccountIds(cfg).some( - (accountId) => resolveSignalAccount({ cfg, accountId }).configured, - ), - resolveStatusLines: async ({ cfg, configured }) => { - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - const signalCliDetected = await detectBinary(signalCliPath); - return [ - `Signal: ${configured ? "configured" : "needs setup"}`, - `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, - ]; - }, - resolveSelectionHint: async ({ cfg }) => { - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - return (await detectBinary(signalCliPath)) ? "signal-cli found" : "signal-cli missing"; - }, - resolveQuickstartScore: async ({ cfg }) => { - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - return (await detectBinary(signalCliPath)) ? 1 : 0; - }, +export const signalSetupWizard: ChannelSetupWizard = createSignalSetupWizardBase({ + resolveStatusLines: async ({ cfg, configured }) => { + const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; + const signalCliDetected = await detectBinary(signalCliPath); + return [ + `Signal: ${configured ? "configured" : "needs setup"}`, + `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, + ]; + }, + resolveSelectionHint: async ({ cfg }) => { + const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; + return (await detectBinary(signalCliPath)) ? "signal-cli found" : "signal-cli missing"; + }, + resolveQuickstartScore: async ({ cfg }) => { + const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; + return (await detectBinary(signalCliPath)) ? 1 : 0; }, prepare: async ({ cfg, accountId, credentialValues, runtime, prompter, options }) => { if (!options?.allowSignalInstall) { @@ -138,50 +65,13 @@ export const signalSetupWizard: ChannelSetupWizard = { await prompter.note(`signal-cli install failed: ${String(error)}`, "Signal"); } }, - credentials: [], - textInputs: [ - { - inputKey: "cliPath", - message: "signal-cli path", - currentValue: ({ cfg, accountId, credentialValues }) => - (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? - resolveSignalAccount({ cfg, accountId }).config.cliPath ?? - "signal-cli", - initialValue: ({ cfg, accountId, credentialValues }) => - (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? - resolveSignalAccount({ cfg, accountId }).config.cliPath ?? - "signal-cli", - shouldPrompt: async ({ currentValue }) => !(await detectBinary(currentValue ?? "signal-cli")), - confirmCurrentValue: false, - applyCurrentValue: true, - helpTitle: "Signal", - helpLines: [ - "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", - ], - }, - { - inputKey: "signalNumber", - message: "Signal bot number (E.164)", - currentValue: ({ cfg, accountId }) => - normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ?? - undefined, - keepPrompt: (value) => `Signal account set (${value}). Keep it?`, - validate: ({ value }) => - normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR, - normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value, - }, - ], - completionNote: { - title: "Signal next steps", - lines: [ - 'Link device with: signal-cli link -n "OpenClaw"', - "Scan QR in Signal -> Linked Devices", - `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, - `Docs: ${formatDocsLink("/signal", "signal")}`, - ], - }, - dmPolicy: signalDmPolicy, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; + shouldPromptCliPath: async ({ currentValue }) => + !(await detectBinary(currentValue ?? "signal-cli")), +}); -export { normalizeSignalAccountInput, parseSignalAllowFromEntries, signalSetupAdapter }; +export { + INVALID_SIGNAL_ACCOUNT_ERROR, + normalizeSignalAccountInput, + promptSignalAllowFrom, + signalSetupAdapter, +}; From 4f7ee60a8f767c5432a2f1a02a6f7731b202ca93 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:29:53 +0000 Subject: [PATCH 061/128] refactor(setup): import docs helpers directly --- extensions/discord/src/setup-core.ts | 2 +- extensions/discord/src/setup-surface.ts | 2 +- extensions/imessage/src/setup-core.ts | 2 +- extensions/slack/src/setup-core.ts | 2 +- extensions/telegram/src/setup-core.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index 6b644fe87c6..f8fd6986439 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -2,7 +2,6 @@ import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - formatDocsLink, migrateBaseNameToDefaultAccount, normalizeAccountId, noteChannelLookupFailure, @@ -18,6 +17,7 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "../../../src/plugin-sdk-internal/setup.js"; +import { formatDocsLink } from "../../../src/terminal/links.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 2a59cbb1ed0..f1d91cf47a8 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,6 +1,5 @@ import { DEFAULT_ACCOUNT_ID, - formatDocsLink, noteChannelLookupFailure, noteChannelLookupSummary, type OpenClawConfig, @@ -16,6 +15,7 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "../../../src/plugin-sdk-internal/setup.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index ada78cc9add..e14fbef47bd 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,7 +1,6 @@ import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - formatDocsLink, migrateBaseNameToDefaultAccount, normalizeAccountId, parseSetupEntriesAllowingWildcard, @@ -16,6 +15,7 @@ import type { ChannelSetupDmPolicy, ChannelSetupWizard, } from "../../../src/plugin-sdk-internal/setup.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 80369d417a7..c9e39afe198 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,7 +1,6 @@ import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - formatDocsLink, hasConfiguredSecretInput, migrateBaseNameToDefaultAccount, normalizeAccountId, @@ -20,6 +19,7 @@ import { type ChannelSetupWizard, type ChannelSetupWizardAllowFromEntry, } from "../../../src/plugin-sdk-internal/setup.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; import { diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index 6ef275ee8b2..5cf76ef139d 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -2,7 +2,6 @@ import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, formatCliCommand, - formatDocsLink, migrateBaseNameToDefaultAccount, normalizeAccountId, patchChannelConfigForAccount, @@ -15,6 +14,7 @@ import type { ChannelSetupAdapter, ChannelSetupDmPolicy, } from "../../../src/plugin-sdk-internal/setup.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; import { fetchTelegramChatId } from "./api-fetch.js"; From a3474dda33531b9dff46f09f65e95746efbcfc4c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:32:46 +0000 Subject: [PATCH 062/128] refactor(discord): share setup wizard base --- extensions/discord/src/setup-core.ts | 144 ++++++++------ extensions/discord/src/setup-surface.ts | 250 ++++++------------------ 2 files changed, 141 insertions(+), 253 deletions(-) diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index f8fd6986439..efcdac05c27 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -138,9 +138,43 @@ export const discordSetupAdapter: ChannelSetupAdapter = { }, }; -export function createDiscordSetupWizardProxy( - loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, -) { +type DiscordAllowFromResolverParams = { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { token?: string }; + entries: string[]; +}; + +type DiscordGroupAllowlistResolverParams = DiscordAllowFromResolverParams & { + prompter: { note: (message: string, title?: string) => Promise }; +}; + +type DiscordGroupAllowlistResolution = Array<{ + input: string; + resolved: boolean; +}>; + +type DiscordSetupWizardHandlers = { + promptAllowFrom: (params: { + cfg: OpenClawConfig; + prompter: import("../../../src/plugin-sdk-internal/setup.js").WizardPrompter; + accountId?: string; + }) => Promise; + resolveAllowFromEntries: (params: DiscordAllowFromResolverParams) => Promise< + Array<{ + input: string; + resolved: boolean; + id: string | null; + }> + >; + resolveGroupAllowlist: ( + params: DiscordGroupAllowlistResolverParams, + ) => Promise; +}; + +export function createDiscordSetupWizardBase( + handlers: DiscordSetupWizardHandlers, +): ChannelSetupWizard { const discordDmPolicy: ChannelSetupDmPolicy = { label: "Discord", channel, @@ -154,13 +188,7 @@ export function createDiscordSetupWizardProxy( channel, dmPolicy: policy, }), - promptAllowFrom: async ({ cfg, prompter, accountId }) => { - const wizard = (await loadWizard()).discordSetupWizard; - if (!wizard.dmPolicy?.promptAllowFrom) { - return cfg; - } - return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); - }, + promptAllowFrom: handlers.promptAllowFrom, }; return { @@ -238,44 +266,22 @@ export function createDiscordSetupWizardProxy( accountId, patch: { groupPolicy: policy }, }), - resolveAllowlist: async ({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { token?: string }; - entries: string[]; - prompter: { note: (message: string, title?: string) => Promise }; - }) => { - const wizard = (await loadWizard()).discordSetupWizard; - if (!wizard.groupAccess?.resolveAllowlist) { - return entries.map((input) => ({ input, resolved: false })); - } + resolveAllowlist: async (params: DiscordGroupAllowlistResolverParams) => { try { - return await wizard.groupAccess.resolveAllowlist({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }); + return await handlers.resolveGroupAllowlist(params); } catch (error) { await noteChannelLookupFailure({ - prompter, + prompter: params.prompter, label: "Discord channels", error, }); await noteChannelLookupSummary({ - prompter, + prompter: params.prompter, label: "Discord channels", resolvedSections: [], - unresolved: entries, + unresolved: params.entries, }); - return entries.map((input) => ({ input, resolved: false })); + return params.entries.map((input) => ({ input, resolved: false })); } }, applyAllowlist: ({ @@ -305,28 +311,7 @@ export function createDiscordSetupWizardProxy( invalidWithoutCredentialNote: "Bot token missing; use numeric user ids (or mention form) only.", parseId: parseDiscordAllowFromId, - resolveEntries: async ({ - cfg, - accountId, - credentialValues, - entries, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { token?: string }; - entries: string[]; - }) => { - const wizard = (await loadWizard()).discordSetupWizard; - if (!wizard.allowFrom) { - return entries.map((input) => ({ input, resolved: false, id: null })); - } - return await wizard.allowFrom.resolveEntries({ - cfg, - accountId, - credentialValues, - entries, - }); - }, + resolveEntries: handlers.resolveAllowFromEntries, apply: async ({ cfg, accountId, @@ -347,3 +332,42 @@ export function createDiscordSetupWizardProxy( disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } + +export function createDiscordSetupWizardProxy( + loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, +) { + return createDiscordSetupWizardBase({ + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, + resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, + resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.groupAccess?.resolveAllowlist) { + return entries.map((input) => ({ input, resolved: false })); + } + return (await wizard.groupAccess.resolveAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + })) as DiscordGroupAllowlistResolution; + }, + }); +} diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index f1d91cf47a8..5f785db6f01 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,27 +1,14 @@ import { - DEFAULT_ACCOUNT_ID, noteChannelLookupFailure, noteChannelLookupSummary, type OpenClawConfig, - parseMentionOrPrefixedId, - patchChannelConfigForAccount, promptLegacyChannelAllowFrom, resolveSetupAccountId, - setLegacyChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, type WizardPrompter, } from "../../../src/plugin-sdk-internal/setup.js"; -import { - type ChannelSetupDmPolicy, - type ChannelSetupWizard, -} from "../../../src/plugin-sdk-internal/setup.js"; +import { type ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; -import { inspectDiscordAccount } from "./account-inspect.js"; -import { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, -} from "./accounts.js"; +import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js"; import { normalizeDiscordSlug } from "./monitor/allow-list.js"; import { resolveDiscordChannelAllowlist, @@ -29,6 +16,7 @@ import { } from "./resolve-channels.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { + createDiscordSetupWizardBase, discordSetupAdapter, DISCORD_TOKEN_HELP_LINES, parseDiscordAllowFromId, @@ -94,186 +82,62 @@ async function promptDiscordAllowFrom(params: { }); } -const discordDmPolicy: ChannelSetupDmPolicy = { - label: "Discord", - channel, - policyKey: "channels.discord.dmPolicy", - allowFromKey: "channels.discord.allowFrom", - getCurrent: (cfg) => - cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => - setLegacyChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), +export const discordSetupWizard: ChannelSetupWizard = createDiscordSetupWizardBase({ promptAllowFrom: promptDiscordAllowFrom, -}; - -export const discordSetupWizard: ChannelSetupWizard = { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs token", - configuredHint: "configured", - unconfiguredHint: "needs token", - configuredScore: 2, - unconfiguredScore: 1, - resolveConfigured: ({ cfg }) => - listDiscordAccountIds(cfg).some( - (accountId) => inspectDiscordAccount({ cfg, accountId }).configured, - ), - }, - credentials: [ - { - inputKey: "token", - providerHint: channel, - credentialLabel: "Discord bot token", - preferredEnvVar: "DISCORD_BOT_TOKEN", - helpTitle: "Discord bot token", - helpLines: DISCORD_TOKEN_HELP_LINES, - envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?", - keepPrompt: "Discord token already configured. Keep it?", - inputPrompt: "Enter Discord bot token", - allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - const account = inspectDiscordAccount({ cfg, accountId }); - return { - accountConfigured: account.configured, - hasConfiguredValue: account.tokenStatus !== "missing", - resolvedValue: account.token?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID - ? process.env.DISCORD_BOT_TOKEN?.trim() || undefined - : undefined, - }; - }, - }, - ], - groupAccess: { - label: "Discord channels", - placeholder: "My Server/#general, guildId/channelId, #support", - currentPolicy: ({ cfg, accountId }) => - resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", - currentEntries: ({ cfg, accountId }) => - Object.entries(resolveDiscordAccount({ cfg, accountId }).config.guilds ?? {}).flatMap( - ([guildKey, value]) => { - const channels = value?.channels ?? {}; - const channelKeys = Object.keys(channels); - if (channelKeys.length === 0) { - const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey; - return [input]; - } - return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`); - }, - ), - updatePrompt: ({ cfg, accountId }) => - Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds), - setPolicy: ({ cfg, accountId, policy }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { groupPolicy: policy }, - }), - resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { - const token = + resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => + await resolveDiscordAllowFromEntries({ + token: resolveDiscordAccount({ cfg, accountId }).token || - (typeof credentialValues.token === "string" ? credentialValues.token : ""); - let resolved: DiscordChannelResolution[] = entries.map((input) => ({ - input, - resolved: false, - })); - if (!token || entries.length === 0) { - return resolved; - } - try { - resolved = await resolveDiscordChannelAllowlist({ - token, - entries, - }); - const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId); - const resolvedGuilds = resolved.filter( - (entry) => entry.resolved && entry.guildId && !entry.channelId, - ); - const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); - await noteChannelLookupSummary({ - prompter, - label: "Discord channels", - resolvedSections: [ - { - title: "Resolved channels", - values: resolvedChannels - .map((entry) => entry.channelId) - .filter((value): value is string => Boolean(value)), - }, - { - title: "Resolved guilds", - values: resolvedGuilds - .map((entry) => entry.guildId) - .filter((value): value is string => Boolean(value)), - }, - ], - unresolved, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: "Discord channels", - error, - }); - } + (typeof credentialValues.token === "string" ? credentialValues.token : ""), + entries, + }), + resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + const token = + resolveDiscordAccount({ cfg, accountId }).token || + (typeof credentialValues.token === "string" ? credentialValues.token : ""); + let resolved: DiscordChannelResolution[] = entries.map((input) => ({ + input, + resolved: false, + })); + if (!token || entries.length === 0) { return resolved; - }, - applyAllowlist: ({ cfg, accountId, resolved }) => { - const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = []; - for (const entry of resolved as DiscordChannelResolution[]) { - const guildKey = - entry.guildId ?? - (entry.guildName ? normalizeDiscordSlug(entry.guildName) : undefined) ?? - "*"; - const channelKey = - entry.channelId ?? - (entry.channelName ? normalizeDiscordSlug(entry.channelName) : undefined); - if (!channelKey && guildKey === "*") { - continue; - } - allowlistEntries.push({ guildKey, ...(channelKey ? { channelKey } : {}) }); - } - return setDiscordGuildChannelAllowlist(cfg, accountId, allowlistEntries); - }, - }, - allowFrom: { - credentialInputKey: "token", - helpTitle: "Discord allowlist", - helpLines: [ - "Allowlist Discord DMs by username (we resolve to user ids).", - "Examples:", - "- 123456789012345678", - "- @alice", - "- alice#1234", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/discord", "discord")}`, - ], - message: "Discord allowFrom (usernames or ids)", - placeholder: "@alice, 123456789012345678", - invalidWithoutCredentialNote: "Bot token missing; use numeric user ids (or mention form) only.", - parseId: parseDiscordAllowFromId, - resolveEntries: async ({ cfg, accountId, credentialValues, entries }) => - await resolveDiscordAllowFromEntries({ - token: - resolveDiscordAccount({ cfg, accountId }).token || - (typeof credentialValues.token === "string" ? credentialValues.token : ""), + } + try { + resolved = await resolveDiscordChannelAllowlist({ + token, entries, - }), - apply: async ({ cfg, accountId, allowFrom }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), + }); + const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId); + const resolvedGuilds = resolved.filter( + (entry) => entry.resolved && entry.guildId && !entry.channelId, + ); + const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); + await noteChannelLookupSummary({ + prompter, + label: "Discord channels", + resolvedSections: [ + { + title: "Resolved channels", + values: resolvedChannels + .map((entry) => entry.channelId) + .filter((value): value is string => Boolean(value)), + }, + { + title: "Resolved guilds", + values: resolvedGuilds + .map((entry) => entry.guildId) + .filter((value): value is string => Boolean(value)), + }, + ], + unresolved, + }); + } catch (error) { + await noteChannelLookupFailure({ + prompter, + label: "Discord channels", + error, + }); + } + return resolved; }, - dmPolicy: discordDmPolicy, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; +}); From b058077b165c209ce1005a8a40c5a541e621470b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:34:36 +0000 Subject: [PATCH 063/128] refactor(telegram): share setup wizard base --- extensions/telegram/src/setup-core.ts | 95 +++++++++++++++++- extensions/telegram/src/setup-surface.ts | 119 ++++------------------- 2 files changed, 112 insertions(+), 102 deletions(-) diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index 5cf76ef139d..0003f602e3d 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -6,6 +6,8 @@ import { normalizeAccountId, patchChannelConfigForAccount, promptResolvedAllowFrom, + setSetupChannelEnabled, + setChannelDmPolicyWithAllowFrom, splitSetupEntries, type OpenClawConfig, type WizardPrompter, @@ -13,9 +15,15 @@ import { import type { ChannelSetupAdapter, ChannelSetupDmPolicy, + ChannelSetupWizard, } from "../../../src/plugin-sdk-internal/setup.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; -import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, +} from "./accounts.js"; import { fetchTelegramChatId } from "./api-fetch.js"; const channel = "telegram" as const; @@ -112,6 +120,91 @@ export async function promptTelegramAllowFromForAccount(params: { }); } +type TelegramSetupWizardHandlers = { + inspectToken: (params: { cfg: OpenClawConfig; accountId: string }) => { + accountConfigured: boolean; + hasConfiguredValue: boolean; + resolvedValue?: string; + envValue?: string; + }; +}; + +export function createTelegramSetupWizardBase( + handlers: TelegramSetupWizardHandlers, +): ChannelSetupWizard { + const dmPolicy: ChannelSetupDmPolicy = { + label: "Telegram", + channel, + policyKey: "channels.telegram.dmPolicy", + allowFromKey: "channels.telegram.allowFrom", + getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptTelegramAllowFromForAccount, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token", + configuredHint: "recommended · configured", + unconfiguredHint: "recommended · newcomer-friendly", + configuredScore: 1, + unconfiguredScore: 10, + resolveConfigured: ({ cfg }) => + listTelegramAccountIds(cfg).some((accountId) => { + const account = inspectTelegramAccount({ cfg, accountId }); + return account.configured; + }), + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "Telegram bot token", + preferredEnvVar: "TELEGRAM_BOT_TOKEN", + helpTitle: "Telegram bot token", + helpLines: TELEGRAM_TOKEN_HELP_LINES, + envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", + keepPrompt: "Telegram token already configured. Keep it?", + inputPrompt: "Enter Telegram bot token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => handlers.inspectToken({ cfg, accountId }), + }, + ], + allowFrom: { + helpTitle: "Telegram user id", + helpLines: TELEGRAM_USER_ID_HELP_LINES, + credentialInputKey: "token", + message: "Telegram allowFrom (numeric sender id; @username resolves to id)", + placeholder: "@username", + invalidWithoutCredentialNote: + "Telegram token missing; use numeric sender ids (usernames require a bot token).", + parseInputs: splitSetupEntries, + parseId: parseTelegramAllowFromId, + resolveEntries: async ({ credentialValues, entries }) => + resolveTelegramAllowFromEntries({ + credentialValue: credentialValues.token, + entries, + }), + apply: async ({ cfg, accountId, allowFrom }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + dmPolicy, + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} + export const telegramSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index 7d95f40728b..4417fc1764a 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -1,113 +1,30 @@ import { DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, - type OpenClawConfig, - patchChannelConfigForAccount, - setChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, - splitSetupEntries, } 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 type { ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; +import { resolveTelegramAccount } from "./accounts.js"; import { + createTelegramSetupWizardBase, parseTelegramAllowFromId, - promptTelegramAllowFromForAccount, - resolveTelegramAllowFromEntries, - TELEGRAM_TOKEN_HELP_LINES, - TELEGRAM_USER_ID_HELP_LINES, telegramSetupAdapter, } from "./setup-core.js"; -const channel = "telegram" as const; - -const dmPolicy: ChannelSetupDmPolicy = { - label: "Telegram", - channel, - policyKey: "channels.telegram.dmPolicy", - allowFromKey: "channels.telegram.allowFrom", - getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptTelegramAllowFromForAccount, -}; - -export const telegramSetupWizard: ChannelSetupWizard = { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs token", - configuredHint: "recommended · configured", - unconfiguredHint: "recommended · newcomer-friendly", - configuredScore: 1, - unconfiguredScore: 10, - resolveConfigured: ({ cfg }) => - listTelegramAccountIds(cfg).some((accountId) => { - const account = inspectTelegramAccount({ cfg, accountId }); - return account.configured; - }), +export const telegramSetupWizard: ChannelSetupWizard = createTelegramSetupWizardBase({ + inspectToken: ({ cfg, accountId }) => { + const resolved = resolveTelegramAccount({ cfg, accountId }); + const hasConfiguredBotToken = hasConfiguredSecretInput(resolved.config.botToken); + const hasConfiguredValue = hasConfiguredBotToken || Boolean(resolved.config.tokenFile?.trim()); + return { + accountConfigured: Boolean(resolved.token) || hasConfiguredValue, + hasConfiguredValue, + resolvedValue: resolved.token?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined + : undefined, + }; }, - credentials: [ - { - inputKey: "token", - providerHint: channel, - credentialLabel: "Telegram bot token", - preferredEnvVar: "TELEGRAM_BOT_TOKEN", - helpTitle: "Telegram bot token", - helpLines: TELEGRAM_TOKEN_HELP_LINES, - envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", - keepPrompt: "Telegram token already configured. Keep it?", - inputPrompt: "Enter Telegram bot token", - allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - const resolved = resolveTelegramAccount({ cfg, accountId }); - const hasConfiguredBotToken = hasConfiguredSecretInput(resolved.config.botToken); - const hasConfiguredValue = - hasConfiguredBotToken || Boolean(resolved.config.tokenFile?.trim()); - return { - accountConfigured: Boolean(resolved.token) || hasConfiguredValue, - hasConfiguredValue, - resolvedValue: resolved.token?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID - ? process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined - : undefined, - }; - }, - }, - ], - allowFrom: { - helpTitle: "Telegram user id", - helpLines: TELEGRAM_USER_ID_HELP_LINES, - credentialInputKey: "token", - message: "Telegram allowFrom (numeric sender id; @username resolves to id)", - placeholder: "@username", - invalidWithoutCredentialNote: - "Telegram token missing; use numeric sender ids (usernames require a bot token).", - parseInputs: splitSetupEntries, - parseId: parseTelegramAllowFromId, - resolveEntries: async ({ credentialValues, entries }) => - resolveTelegramAllowFromEntries({ - credentialValue: credentialValues.token, - entries, - }), - apply: async ({ cfg, accountId, allowFrom }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), - }, - dmPolicy, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; +}); export { parseTelegramAllowFromId, telegramSetupAdapter }; From a0e7e3c3cd02d0f6368fcc17aa9db9c821cebea4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:37:50 +0000 Subject: [PATCH 064/128] refactor(discord): share plugin base config --- extensions/discord/src/channel.setup.ts | 45 ++---------- extensions/discord/src/channel.ts | 49 +++---------- extensions/discord/src/shared.ts | 94 +++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 80 deletions(-) create mode 100644 extensions/discord/src/shared.ts diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts index 3d1e9d30ba5..5c7bfe6e659 100644 --- a/extensions/discord/src/channel.setup.ts +++ b/extensions/discord/src/channel.setup.ts @@ -1,43 +1,8 @@ -import { - buildChannelConfigSchema, - DiscordConfigSchema, - getChatChannelMeta, - type ChannelPlugin, -} from "../../../src/plugin-sdk-internal/discord.js"; -import { type ResolvedDiscordAccount } from "./accounts.js"; -import { discordConfigAccessors, discordConfigBase, discordSetupWizard } from "./plugin-shared.js"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/discord"; +import type { ResolvedDiscordAccount } from "./accounts.js"; import { discordSetupAdapter } from "./setup-core.js"; +import { createDiscordPluginBase } from "./shared.js"; -export const discordSetupPlugin: ChannelPlugin = { - id: "discord", - meta: { - ...getChatChannelMeta("discord"), - }, - setupWizard: discordSetupWizard, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - polls: true, - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.discord"] }, - configSchema: buildChannelConfigSchema(DiscordConfigSchema), - config: { - ...discordConfigBase, - isConfigured: (account) => Boolean(account.token?.trim()), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.token?.trim()), - tokenSource: account.tokenSource, - }), - ...discordConfigAccessors, - }, +export const discordSetupPlugin: ChannelPlugin = createDiscordPluginBase({ setup: discordSetupAdapter, -}; +}); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 7b70feabbcd..68e12e1e78b 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,23 +1,19 @@ import { Separator, TextDisplay } from "@buape/carbon"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, - collectOpenGroupPolicyConfiguredRouteWarnings, collectOpenProviderGroupPolicyWarnings, -} from "../../../src/plugin-sdk-internal/channel-config.js"; + collectOpenGroupPolicyConfiguredRouteWarnings, +} from "openclaw/plugin-sdk/compat"; import { buildAgentSessionKey, resolveThreadSessionKeys, type RoutePeer, -} from "../../../src/plugin-sdk-internal/core.js"; +} from "openclaw/plugin-sdk/core"; import { buildComputedAccountStatusSnapshot, - buildChannelConfigSchema, buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, - DiscordConfigSchema, - getChatChannelMeta, listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, PAIRING_APPROVED_MESSAGE, @@ -28,7 +24,8 @@ import { type ChannelMessageActionAdapter, type ChannelPlugin, type OpenClawConfig, -} from "../../../src/plugin-sdk-internal/discord.js"; +} from "openclaw/plugin-sdk/discord"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; import { listDiscordAccountIds, @@ -45,12 +42,12 @@ import { normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, } from "./normalize.js"; -import { discordConfigAccessors, discordConfigBase, discordSetupWizard } from "./plugin-shared.js"; import type { DiscordProbe } from "./probe.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { getDiscordRuntime } from "./runtime.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; import { discordSetupAdapter } from "./setup-core.js"; +import { createDiscordPluginBase, discordConfigAccessors } from "./shared.js"; import { collectDiscordStatusIssues } from "./status-issues.js"; import { parseDiscordTarget } from "./targets.js"; import { DiscordUiContainer } from "./ui.js"; @@ -59,7 +56,6 @@ type DiscordSendFn = ReturnType< typeof getDiscordRuntime >["channel"]["discord"]["sendMessageDiscord"]; -const meta = getChatChannelMeta("discord"); const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; function formatDiscordIntents(intents?: { @@ -297,11 +293,9 @@ function resolveDiscordOutboundSessionRoute(params: { } export const discordPlugin: ChannelPlugin = { - id: "discord", - meta: { - ...meta, - }, - setupWizard: discordSetupWizard, + ...createDiscordPluginBase({ + setup: discordSetupAdapter, + }), pairing: { idLabel: "discordUserId", normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), @@ -312,31 +306,6 @@ export const discordPlugin: ChannelPlugin = { ); }, }, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - polls: true, - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.discord"] }, - configSchema: buildChannelConfigSchema(DiscordConfigSchema), - config: { - ...discordConfigBase, - isConfigured: (account) => Boolean(account.token?.trim()), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.token?.trim()), - tokenSource: account.tokenSource, - }), - ...discordConfigAccessors, - }, allowlist: { supportsScope: ({ scope }) => scope === "dm", readConfig: ({ cfg, accountId }) => diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts new file mode 100644 index 00000000000..6a691252052 --- /dev/null +++ b/extensions/discord/src/shared.ts @@ -0,0 +1,94 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { + createScopedAccountConfigAccessors, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DiscordConfigSchema, + getChatChannelMeta, + type ChannelPlugin, +} from "openclaw/plugin-sdk/discord"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, + type ResolvedDiscordAccount, +} from "./accounts.js"; +import { createDiscordSetupWizardProxy } from "./setup-core.js"; + +export const DISCORD_CHANNEL = "discord" as const; + +async function loadDiscordChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ + discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, +})); + +export const discordConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, +}); + +export const discordConfigBase = createScopedChannelConfigBase({ + sectionKey: DISCORD_CHANNEL, + listAccountIds: listDiscordAccountIds, + resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultDiscordAccountId, + clearBaseFields: ["token", "name"], +}); + +export function createDiscordPluginBase(params: { + setup: NonNullable["setup"]>; +}): Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "streaming" + | "reload" + | "configSchema" + | "config" + | "setup" +> { + return { + id: DISCORD_CHANNEL, + meta: { + ...getChatChannelMeta(DISCORD_CHANNEL), + }, + setupWizard: discordSetupWizard, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + polls: true, + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.discord"] }, + configSchema: buildChannelConfigSchema(DiscordConfigSchema), + config: { + ...discordConfigBase, + isConfigured: (account) => Boolean(account.token?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.token?.trim()), + tokenSource: account.tokenSource, + }), + ...discordConfigAccessors, + }, + setup: params.setup, + }; +} From 9c48321176b2dbf924cab648b7e29b96e90aa6d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:40:41 +0000 Subject: [PATCH 065/128] refactor(imessage): share setup wizard base --- extensions/imessage/src/setup-core.ts | 50 +++++--- extensions/imessage/src/setup-surface.ts | 154 +++-------------------- 2 files changed, 55 insertions(+), 149 deletions(-) diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index e14fbef47bd..4304a482ad6 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -155,9 +155,18 @@ export const imessageSetupAdapter: ChannelSetupAdapter = { }, }; -export function createIMessageSetupWizardProxy( - loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>, -) { +type IMessageSetupWizardHandlers = { + resolveStatusLines: NonNullable["resolveStatusLines"]; + resolveSelectionHint: NonNullable["resolveSelectionHint"]; + resolveQuickstartScore: NonNullable["resolveQuickstartScore"]; + shouldPromptCliPath: NonNullable< + NonNullable[number]["shouldPrompt"] + >; +}; + +export function createIMessageSetupWizardBase( + handlers: IMessageSetupWizardHandlers, +): ChannelSetupWizard { const imessageDmPolicy: ChannelSetupDmPolicy = { label: "iMessage", channel, @@ -193,12 +202,9 @@ export function createIMessageSetupWizardProxy( account.config.region, ); }), - resolveStatusLines: async (params) => - (await loadWizard()).imessageSetupWizard.status.resolveStatusLines?.(params) ?? [], - resolveSelectionHint: async (params) => - await (await loadWizard()).imessageSetupWizard.status.resolveSelectionHint?.(params), - resolveQuickstartScore: async (params) => - await (await loadWizard()).imessageSetupWizard.status.resolveQuickstartScore?.(params), + resolveStatusLines: handlers.resolveStatusLines, + resolveSelectionHint: handlers.resolveSelectionHint, + resolveQuickstartScore: handlers.resolveQuickstartScore, }, credentials: [], textInputs: [ @@ -209,12 +215,7 @@ export function createIMessageSetupWizardProxy( resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", currentValue: ({ cfg, accountId }) => resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", - shouldPrompt: async (params) => { - const input = (await loadWizard()).imessageSetupWizard.textInputs?.find( - (entry) => entry.inputKey === "cliPath", - ); - return (await input?.shouldPrompt?.(params)) ?? false; - }, + shouldPrompt: handlers.shouldPromptCliPath, confirmCurrentValue: false, applyCurrentValue: true, helpTitle: "iMessage", @@ -235,3 +236,22 @@ export function createIMessageSetupWizardProxy( disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } + +export function createIMessageSetupWizardProxy( + loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>, +) { + return createIMessageSetupWizardBase({ + resolveStatusLines: async (params) => + (await loadWizard()).imessageSetupWizard.status.resolveStatusLines?.(params) ?? [], + resolveSelectionHint: async (params) => + await (await loadWizard()).imessageSetupWizard.status.resolveSelectionHint?.(params), + resolveQuickstartScore: async (params) => + await (await loadWizard()).imessageSetupWizard.status.resolveQuickstartScore?.(params), + shouldPromptCliPath: async (params) => { + const input = (await loadWizard()).imessageSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }, + }); +} diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index b8487dff54d..c1158960cec 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,137 +1,23 @@ -import { - DEFAULT_ACCOUNT_ID, - detectBinary, - formatDocsLink, - type OpenClawConfig, - parseSetupEntriesAllowingWildcard, - promptParsedAllowFromForScopedChannel, - setChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, - type WizardPrompter, -} from "../../../src/plugin-sdk-internal/setup.js"; -import type { - ChannelSetupDmPolicy, - ChannelSetupWizard, -} from "../../../src/plugin-sdk-internal/setup.js"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, -} from "./accounts.js"; -import { imessageSetupAdapter, parseIMessageAllowFromEntries } from "./setup-core.js"; +import { detectBinary } from "../../../src/plugin-sdk-internal/setup.js"; +import { createIMessageSetupWizardBase, imessageSetupAdapter } from "./setup-core.js"; -const channel = "imessage" as const; - -async function promptIMessageAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - return promptParsedAllowFromForScopedChannel({ - cfg: params.cfg, - channel, - accountId: params.accountId, - defaultAccountId: resolveDefaultIMessageAccountId(params.cfg), - prompter: params.prompter, - noteTitle: "iMessage allowlist", - noteLines: [ - "Allowlist iMessage DMs by handle or chat target.", - "Examples:", - "- +15555550123", - "- user@example.com", - "- chat_id:123", - "- chat_guid:... or chat_identifier:...", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/imessage", "imessage")}`, - ], - message: "iMessage allowFrom (handle or chat_id)", - placeholder: "+15555550123, user@example.com, chat_id:123", - parseEntries: parseIMessageAllowFromEntries, - getExistingAllowFrom: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? [], - }); -} - -const imessageDmPolicy: ChannelSetupDmPolicy = { - label: "iMessage", - channel, - policyKey: "channels.imessage.dmPolicy", - allowFromKey: "channels.imessage.allowFrom", - getCurrent: (cfg) => cfg.channels?.imessage?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptIMessageAllowFrom, -}; - -export const imessageSetupWizard: ChannelSetupWizard = { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs setup", - configuredHint: "imsg found", - unconfiguredHint: "imsg missing", - configuredScore: 1, - unconfiguredScore: 0, - resolveConfigured: ({ cfg }) => - listIMessageAccountIds(cfg).some((accountId) => { - const account = resolveIMessageAccount({ cfg, accountId }); - return Boolean( - account.config.cliPath || - account.config.dbPath || - account.config.allowFrom || - account.config.service || - account.config.region, - ); - }), - resolveStatusLines: async ({ cfg, configured }) => { - const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - const cliDetected = await detectBinary(cliPath); - return [ - `iMessage: ${configured ? "configured" : "needs setup"}`, - `imsg: ${cliDetected ? "found" : "missing"} (${cliPath})`, - ]; - }, - resolveSelectionHint: async ({ cfg }) => { - const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - return (await detectBinary(cliPath)) ? "imsg found" : "imsg missing"; - }, - resolveQuickstartScore: async ({ cfg }) => { - const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - return (await detectBinary(cliPath)) ? 1 : 0; - }, +export const imessageSetupWizard = createIMessageSetupWizardBase({ + resolveStatusLines: async ({ cfg, configured }) => { + const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; + const cliDetected = await detectBinary(cliPath); + return [ + `iMessage: ${configured ? "configured" : "needs setup"}`, + `imsg: ${cliDetected ? "found" : "missing"} (${cliPath})`, + ]; }, - credentials: [], - textInputs: [ - { - inputKey: "cliPath", - message: "imsg CLI path", - initialValue: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", - currentValue: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", - shouldPrompt: async ({ currentValue }) => !(await detectBinary(currentValue ?? "imsg")), - confirmCurrentValue: false, - applyCurrentValue: true, - helpTitle: "iMessage", - helpLines: ["imsg CLI path required to enable iMessage."], - }, - ], - completionNote: { - title: "iMessage next steps", - lines: [ - "This is still a work in progress.", - "Ensure OpenClaw has Full Disk Access to Messages DB.", - "Grant Automation permission for Messages when prompted.", - "List chats with: imsg chats --limit 20", - `Docs: ${formatDocsLink("/imessage", "imessage")}`, - ], + resolveSelectionHint: async ({ cfg }) => { + const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; + return (await detectBinary(cliPath)) ? "imsg found" : "imsg missing"; }, - dmPolicy: imessageDmPolicy, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; - -export { imessageSetupAdapter, parseIMessageAllowFromEntries }; + resolveQuickstartScore: async ({ cfg }) => { + const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; + return (await detectBinary(cliPath)) ? 1 : 0; + }, + shouldPromptCliPath: async ({ currentValue }) => !(await detectBinary(currentValue ?? "imsg")), +}); +export { imessageSetupAdapter, parseIMessageAllowFromEntries } from "./setup-core.js"; From 7fc134d74e5c8528babf87cb115eb4cfce7c9122 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:43:13 +0000 Subject: [PATCH 066/128] refactor(setup): share patched account adapters --- extensions/googlechat/src/setup-core.ts | 57 +++++----------------- extensions/zalo/src/setup-core.ts | 49 +++---------------- src/channels/plugins/setup-helpers.test.ts | 54 +++++++++++++++++++- src/channels/plugins/setup-helpers.ts | 45 +++++++++++++++++ 4 files changed, 119 insertions(+), 86 deletions(-) diff --git a/extensions/googlechat/src/setup-core.ts b/extensions/googlechat/src/setup-core.ts index b12d2704b2d..09980bad5cd 100644 --- a/extensions/googlechat/src/setup-core.ts +++ b/extensions/googlechat/src/setup-core.ts @@ -1,23 +1,10 @@ -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - DEFAULT_ACCOUNT_ID, - migrateBaseNameToDefaultAccount, - normalizeAccountId, - type ChannelSetupAdapter, -} from "openclaw/plugin-sdk/setup"; +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; const channel = "googlechat" as const; -export const googlechatSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const googlechatSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."; @@ -27,20 +14,7 @@ export const googlechatSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; + buildPatch: (input) => { const patch = input.useEnv ? {} : input.tokenFile @@ -52,17 +26,12 @@ export const googlechatSetupAdapter: ChannelSetupAdapter = { const audience = input.audience?.trim(); const webhookPath = input.webhookPath?.trim(); const webhookUrl = input.webhookUrl?.trim(); - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: { - ...patch, - ...(audienceType ? { audienceType } : {}), - ...(audience ? { audience } : {}), - ...(webhookPath ? { webhookPath } : {}), - ...(webhookUrl ? { webhookUrl } : {}), - }, - }); + return { + ...patch, + ...(audienceType ? { audienceType } : {}), + ...(audience ? { audience } : {}), + ...(webhookPath ? { webhookPath } : {}), + ...(webhookUrl ? { webhookUrl } : {}), + }; }, -}; +}); diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts index fd6d09449ad..3e54c5a86dc 100644 --- a/extensions/zalo/src/setup-core.ts +++ b/extensions/zalo/src/setup-core.ts @@ -1,23 +1,10 @@ -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - DEFAULT_ACCOUNT_ID, - migrateBaseNameToDefaultAccount, - normalizeAccountId, - type ChannelSetupAdapter, -} from "openclaw/plugin-sdk/setup"; +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; const channel = "zalo" as const; -export const zaloSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const zaloSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "ZALO_BOT_TOKEN can only be used for the default account."; @@ -27,32 +14,12 @@ export const zaloSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - const patch = input.useEnv + buildPatch: (input) => + input.useEnv ? {} : input.tokenFile ? { tokenFile: input.tokenFile } : input.token ? { botToken: input.token } - : {}; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch, - }); - }, -}; + : {}, +}); diff --git a/src/channels/plugins/setup-helpers.test.ts b/src/channels/plugins/setup-helpers.test.ts index 10069c0b9f4..d0c6b1c8a74 100644 --- a/src/channels/plugins/setup-helpers.test.ts +++ b/src/channels/plugins/setup-helpers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; -import { applySetupAccountConfigPatch } from "./setup-helpers.js"; +import { applySetupAccountConfigPatch, createPatchedAccountSetupAdapter } from "./setup-helpers.js"; function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; @@ -79,3 +79,55 @@ describe("applySetupAccountConfigPatch", () => { }); }); }); + +describe("createPatchedAccountSetupAdapter", () => { + it("stores default-account patch at channel root", () => { + const adapter = createPatchedAccountSetupAdapter({ + channelKey: "zalo", + buildPatch: (input) => ({ botToken: input.token }), + }); + + const next = adapter.applyAccountConfig({ + cfg: asConfig({ channels: { zalo: { enabled: false } } }), + accountId: DEFAULT_ACCOUNT_ID, + input: { name: "Personal", token: "tok" }, + }); + + expect(next.channels?.zalo).toMatchObject({ + enabled: true, + name: "Personal", + botToken: "tok", + }); + }); + + it("migrates base name into the default account before patching a named account", () => { + const adapter = createPatchedAccountSetupAdapter({ + channelKey: "zalo", + buildPatch: (input) => ({ botToken: input.token }), + }); + + const next = adapter.applyAccountConfig({ + cfg: asConfig({ + channels: { + zalo: { + name: "Personal", + accounts: { + work: { botToken: "old" }, + }, + }, + }, + }), + accountId: "Work Team", + input: { name: "Work", token: "new" }, + }); + + expect(next.channels?.zalo).toMatchObject({ + accounts: { + default: { name: "Personal" }, + work: { botToken: "old" }, + "work-team": { enabled: true, name: "Work", botToken: "new" }, + }, + }); + expect(next.channels?.zalo).not.toHaveProperty("name"); + }); +}); diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index d592a56e475..31ba2c7d9c6 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -1,5 +1,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import type { ChannelSetupAdapter } from "./types.adapters.js"; +import type { ChannelSetupInput } from "./types.core.js"; type ChannelSectionBase = { name?: string; @@ -134,6 +136,49 @@ export function applySetupAccountConfigPatch(params: { }); } +export function createPatchedAccountSetupAdapter(params: { + channelKey: string; + alwaysUseAccounts?: boolean; + validateInput?: ChannelSetupAdapter["validateInput"]; + buildPatch: (input: ChannelSetupInput) => Record; +}): ChannelSetupAdapter { + return { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: params.channelKey, + accountId, + name, + alwaysUseAccounts: params.alwaysUseAccounts, + }), + validateInput: params.validateInput, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: params.channelKey, + accountId, + name: input.name, + alwaysUseAccounts: params.alwaysUseAccounts, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: params.channelKey, + alwaysUseAccounts: params.alwaysUseAccounts, + }) + : namedConfig; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: params.channelKey, + accountId, + patch: params.buildPatch(input), + }); + }, + }; +} + export function patchScopedAccountConfig(params: { cfg: OpenClawConfig; channelKey: string; From 81ef52a81ebc6183544ae681f0a922a7aee9e3de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:44:10 +0000 Subject: [PATCH 067/128] refactor(zalouser): reuse patched setup adapter --- extensions/zalouser/src/setup-core.ts | 44 +++------------------------ 1 file changed, 5 insertions(+), 39 deletions(-) diff --git a/extensions/zalouser/src/setup-core.ts b/extensions/zalouser/src/setup-core.ts index 9e66e2c63c6..f3215a16469 100644 --- a/extensions/zalouser/src/setup-core.ts +++ b/extensions/zalouser/src/setup-core.ts @@ -1,43 +1,9 @@ -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - DEFAULT_ACCOUNT_ID, - migrateBaseNameToDefaultAccount, - normalizeAccountId, - type ChannelSetupAdapter, -} from "openclaw/plugin-sdk/setup"; +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; const channel = "zalouser" as const; -export const zalouserSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const zalouserSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: () => null, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: {}, - }); - }, -}; + buildPatch: () => ({}), +}); From 4fd75e5fc8ae06a9291debf3f548205489617717 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:46:03 +0000 Subject: [PATCH 068/128] refactor(setup): reuse patched adapters in slack and telegram --- extensions/slack/src/setup-core.ts | 74 ++++-------------------- extensions/telegram/src/setup-core.ts | 81 ++++----------------------- 2 files changed, 22 insertions(+), 133 deletions(-) diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index c9e39afe198..c024bed75d8 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,9 +1,7 @@ +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import { - applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, - migrateBaseNameToDefaultAccount, - normalizeAccountId, type OpenClawConfig, noteChannelLookupFailure, noteChannelLookupSummary, @@ -38,15 +36,8 @@ function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawCon }); } -export const slackSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const slackSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "Slack env tokens can only be used for the default account."; @@ -56,59 +47,14 @@ export const slackSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - ...(input.useEnv - ? {} - : { - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }), - }, + buildPatch: (input) => + input.useEnv + ? {} + : { + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), }, - }; - } - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - accounts: { - ...next.channels?.slack?.accounts, - [accountId]: { - ...next.channels?.slack?.accounts?.[accountId], - enabled: true, - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }, - }, - }, - }, - }; - }, -}; +}); type SlackAllowFromResolverParams = { cfg: OpenClawConfig; diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index 0003f602e3d..33ce824d17d 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,9 +1,7 @@ +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import { - applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, formatCliCommand, - migrateBaseNameToDefaultAccount, - normalizeAccountId, patchChannelConfigForAccount, promptResolvedAllowFrom, setSetupChannelEnabled, @@ -205,15 +203,8 @@ export function createTelegramSetupWizardBase( } satisfies ChannelSetupWizard; } -export const telegramSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const telegramSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "TELEGRAM_BOT_TOKEN can only be used for the default account."; @@ -223,60 +214,12 @@ export const telegramSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - telegram: { - ...next.channels?.telegram, - enabled: true, - ...(input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - telegram: { - ...next.channels?.telegram, - enabled: true, - accounts: { - ...next.channels?.telegram?.accounts, - [accountId]: { - ...next.channels?.telegram?.accounts?.[accountId], - enabled: true, - ...(input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}), - }, - }, - }, - }, - }; - }, -}; + buildPatch: (input) => + input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}, +}); From 387d9fa7c409b8d9d1e1b769f69cc532457923f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:46:56 +0000 Subject: [PATCH 069/128] refactor(setup): reuse patched adapters in discord and signal --- extensions/discord/src/setup-core.ts | 64 +++------------------------ extensions/signal/src/setup-core.ts | 65 +++------------------------- 2 files changed, 10 insertions(+), 119 deletions(-) diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index efcdac05c27..fe2b559a975 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,9 +1,7 @@ +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; import { - applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - migrateBaseNameToDefaultAccount, - normalizeAccountId, noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, @@ -72,15 +70,8 @@ export function parseDiscordAllowFromId(value: string): string | null { }); } -export const discordSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const discordSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "DISCORD_BOT_TOKEN can only be used for the default account."; @@ -90,53 +81,8 @@ export const discordSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - discord: { - ...next.channels?.discord, - enabled: true, - ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - discord: { - ...next.channels?.discord, - enabled: true, - accounts: { - ...next.channels?.discord?.accounts, - [accountId]: { - ...next.channels?.discord?.accounts?.[accountId], - enabled: true, - ...(input.token ? { token: input.token } : {}), - }, - }, - }, - }, - }; - }, -}; + buildPatch: (input) => (input.useEnv ? {} : input.token ? { token: input.token } : {}), +}); type DiscordAllowFromResolverParams = { cfg: OpenClawConfig; diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 1e2ea595756..5e3901f0fae 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,8 +1,5 @@ +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import { - applyAccountNameToChannelSection, - DEFAULT_ACCOUNT_ID, - migrateBaseNameToDefaultAccount, - normalizeAccountId, normalizeE164, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, @@ -114,15 +111,8 @@ export async function promptSignalAllowFrom(params: { }); } -export const signalSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const signalSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ input }) => { if ( !input.signalNumber && @@ -135,53 +125,8 @@ export const signalSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - signal: { - ...next.channels?.signal, - enabled: true, - ...buildSignalSetupPatch(input), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - signal: { - ...next.channels?.signal, - enabled: true, - accounts: { - ...next.channels?.signal?.accounts, - [accountId]: { - ...next.channels?.signal?.accounts?.[accountId], - enabled: true, - ...buildSignalSetupPatch(input), - }, - }, - }, - }, - }; - }, -}; + buildPatch: (input) => buildSignalSetupPatch(input), +}); type SignalSetupWizardHandlers = { resolveStatusLines: NonNullable["resolveStatusLines"]; From 5ddbba1c70c86da87ee3bd01bbff4af0c8ee4ddc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:47:42 +0000 Subject: [PATCH 070/128] refactor(imessage): reuse patched setup adapter --- extensions/imessage/src/setup-core.ts | 65 +++------------------------ 1 file changed, 5 insertions(+), 60 deletions(-) diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 4304a482ad6..45f385e0691 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,8 +1,5 @@ +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import { - applyAccountNameToChannelSection, - DEFAULT_ACCOUNT_ID, - migrateBaseNameToDefaultAccount, - normalizeAccountId, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, @@ -98,62 +95,10 @@ async function promptIMessageAllowFrom(params: { }); } -export const imessageSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - imessage: { - ...next.channels?.imessage, - enabled: true, - ...buildIMessageSetupPatch(input), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - imessage: { - ...next.channels?.imessage, - enabled: true, - accounts: { - ...next.channels?.imessage?.accounts, - [accountId]: { - ...next.channels?.imessage?.accounts?.[accountId], - enabled: true, - ...buildIMessageSetupPatch(input), - }, - }, - }, - }, - }; - }, -}; +export const imessageSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, + buildPatch: (input) => buildIMessageSetupPatch(input), +}); type IMessageSetupWizardHandlers = { resolveStatusLines: NonNullable["resolveStatusLines"]; From 78869f1517883fdc1561af31652e750c8df4dcd9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:48:49 +0000 Subject: [PATCH 071/128] refactor(mattermost): reuse patched setup adapter --- extensions/mattermost/src/setup-core.ts | 50 ++++++------------------- 1 file changed, 11 insertions(+), 39 deletions(-) diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 946b1af728e..45bfbc5ac82 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -1,12 +1,9 @@ import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, - migrateBaseNameToDefaultAccount, - normalizeAccountId, type OpenClawConfig, } from "openclaw/plugin-sdk/mattermost"; +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; @@ -27,15 +24,8 @@ export function resolveMattermostAccountWithSecrets(cfg: OpenClawConfig, account }); } -export const mattermostSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const mattermostSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ accountId, input }) => { const token = input.botToken ?? input.token; const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); @@ -50,32 +40,14 @@ export const mattermostSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { + buildPatch: (input) => { const token = input.botToken ?? input.token; const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: input.useEnv - ? {} - : { - ...(token ? { botToken: token } : {}), - ...(baseUrl ? { baseUrl } : {}), - }, - }); + return input.useEnv + ? {} + : { + ...(token ? { botToken: token } : {}), + ...(baseUrl ? { baseUrl } : {}), + }; }, -}; +}); From c51842660f35baaaf3bb85580f137c855c7d1a5c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:51:02 +0000 Subject: [PATCH 072/128] refactor(setup): support account-scoped default patches --- extensions/whatsapp/src/setup-core.ts | 58 ++++------------------ src/channels/plugins/setup-helpers.test.ts | 26 ++++++++++ src/channels/plugins/setup-helpers.ts | 14 ++++-- 3 files changed, 46 insertions(+), 52 deletions(-) diff --git a/extensions/whatsapp/src/setup-core.ts b/extensions/whatsapp/src/setup-core.ts index a4471eb8188..346c9aa0e8d 100644 --- a/extensions/whatsapp/src/setup-core.ts +++ b/extensions/whatsapp/src/setup-core.ts @@ -1,52 +1,12 @@ -import { - applyAccountNameToChannelSection, - type ChannelSetupAdapter, - migrateBaseNameToDefaultAccount, - normalizeAccountId, -} from "../../../src/plugin-sdk-internal/setup.js"; +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/plugin-sdk-internal/setup.js"; const channel = "whatsapp" as const; -export const whatsappSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - alwaysUseAccounts: true, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - alwaysUseAccounts: true, - }); - const next = migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - alwaysUseAccounts: true, - }); - const entry = { - ...next.channels?.whatsapp?.accounts?.[accountId], - ...(input.authDir ? { authDir: input.authDir } : {}), - enabled: true, - }; - return { - ...next, - channels: { - ...next.channels, - whatsapp: { - ...next.channels?.whatsapp, - accounts: { - ...next.channels?.whatsapp?.accounts, - [accountId]: entry, - }, - }, - }, - }; - }, -}; +export const whatsappSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, + alwaysUseAccounts: true, + buildPatch: (input) => ({ + ...(input.authDir ? { authDir: input.authDir } : {}), + }), +}); diff --git a/src/channels/plugins/setup-helpers.test.ts b/src/channels/plugins/setup-helpers.test.ts index d0c6b1c8a74..c45e13a9d7f 100644 --- a/src/channels/plugins/setup-helpers.test.ts +++ b/src/channels/plugins/setup-helpers.test.ts @@ -130,4 +130,30 @@ describe("createPatchedAccountSetupAdapter", () => { }); expect(next.channels?.zalo).not.toHaveProperty("name"); }); + + it("can store the default account in accounts.default", () => { + const adapter = createPatchedAccountSetupAdapter({ + channelKey: "whatsapp", + alwaysUseAccounts: true, + buildPatch: (input) => ({ authDir: input.authDir }), + }); + + const next = adapter.applyAccountConfig({ + cfg: asConfig({ channels: { whatsapp: {} } }), + accountId: DEFAULT_ACCOUNT_ID, + input: { name: "Phone", authDir: "/tmp/auth" }, + }); + + expect(next.channels?.whatsapp).toMatchObject({ + accounts: { + default: { + enabled: true, + name: "Phone", + authDir: "/tmp/auth", + }, + }, + }); + expect(next.channels?.whatsapp).not.toHaveProperty("enabled"); + expect(next.channels?.whatsapp).not.toHaveProperty("authDir"); + }); }); diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index 31ba2c7d9c6..d4f618e870f 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -139,6 +139,8 @@ export function applySetupAccountConfigPatch(params: { export function createPatchedAccountSetupAdapter(params: { channelKey: string; alwaysUseAccounts?: boolean; + ensureChannelEnabled?: boolean; + ensureAccountEnabled?: boolean; validateInput?: ChannelSetupAdapter["validateInput"]; buildPatch: (input: ChannelSetupInput) => Record; }): ChannelSetupAdapter { @@ -169,11 +171,16 @@ export function createPatchedAccountSetupAdapter(params: { alwaysUseAccounts: params.alwaysUseAccounts, }) : namedConfig; - return applySetupAccountConfigPatch({ + const patch = params.buildPatch(input); + return patchScopedAccountConfig({ cfg: next, channelKey: params.channelKey, accountId, - patch: params.buildPatch(input), + patch, + accountPatch: patch, + ensureChannelEnabled: params.ensureChannelEnabled ?? !params.alwaysUseAccounts, + ensureAccountEnabled: params.ensureAccountEnabled ?? true, + scopeDefaultToAccounts: params.alwaysUseAccounts, }); }, }; @@ -187,6 +194,7 @@ export function patchScopedAccountConfig(params: { accountPatch?: Record; ensureChannelEnabled?: boolean; ensureAccountEnabled?: boolean; + scopeDefaultToAccounts?: boolean; }): OpenClawConfig { const accountId = normalizeAccountId(params.accountId); const channels = params.cfg.channels as Record | undefined; @@ -201,7 +209,7 @@ export function patchScopedAccountConfig(params: { const ensureAccountEnabled = params.ensureAccountEnabled ?? ensureChannelEnabled; const patch = params.patch; const accountPatch = params.accountPatch ?? patch; - if (accountId === DEFAULT_ACCOUNT_ID) { + if (accountId === DEFAULT_ACCOUNT_ID && !params.scopeDefaultToAccounts) { return { ...params.cfg, channels: { From 4ae71485e9111bc2eb1c39a40c05e1a812d0f4e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:53:17 +0000 Subject: [PATCH 073/128] refactor(setup): share scoped config prelude --- extensions/bluebubbles/src/setup-core.ts | 28 +++++-------- extensions/irc/src/setup-core.ts | 6 +-- extensions/matrix/src/setup-core.ts | 28 ++++--------- extensions/nextcloud-talk/src/setup-core.ts | 6 +-- extensions/tlon/src/setup-core.ts | 17 ++++---- src/channels/plugins/setup-helpers.test.ts | 46 ++++++++++++++++++++- src/channels/plugins/setup-helpers.ts | 25 +++++++++++ 7 files changed, 103 insertions(+), 53 deletions(-) diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts index 6509c5f240b..408cd255cf3 100644 --- a/extensions/bluebubbles/src/setup-core.ts +++ b/extensions/bluebubbles/src/setup-core.ts @@ -1,14 +1,12 @@ import { - applyAccountNameToChannelSection, - DEFAULT_ACCOUNT_ID, - migrateBaseNameToDefaultAccount, patchScopedAccountConfig, - normalizeAccountId, - setTopLevelChannelDmPolicyWithAllowFrom, - type ChannelSetupAdapter, - type DmPolicy, - type OpenClawConfig, -} from "openclaw/plugin-sdk/setup"; + prepareScopedSetupConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/setup-wizard-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; const channel = "bluebubbles" as const; @@ -39,7 +37,7 @@ export function setBlueBubblesAllowFrom( export const blueBubblesSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ + prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, @@ -58,19 +56,13 @@ export const blueBubblesSetupAdapter: ChannelSetupAdapter = { return null; }, applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ + const next = prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, name: input.name, + migrateBaseName: true, }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; return applyBlueBubblesConnectionConfig({ cfg: next, accountId, diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index c793098063b..3c28017e1e9 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -1,6 +1,6 @@ import { - applyAccountNameToChannelSection, patchScopedAccountConfig, + prepareScopedSetupConfig, } from "../../../src/channels/plugins/setup-helpers.js"; import { setTopLevelChannelAllowFrom, @@ -100,7 +100,7 @@ export function setIrcGroupAccess( export const ircSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ + prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, @@ -118,7 +118,7 @@ export const ircSetupAdapter: ChannelSetupAdapter = { }, applyAccountConfig: ({ cfg, accountId, input }) => { const setupInput = input as IrcSetupInput; - const namedConfig = applyAccountNameToChannelSection({ + const namedConfig = prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, diff --git a/extensions/matrix/src/setup-core.ts b/extensions/matrix/src/setup-core.ts index d78049262a1..2e6bc895e0c 100644 --- a/extensions/matrix/src/setup-core.ts +++ b/extensions/matrix/src/setup-core.ts @@ -1,11 +1,7 @@ -import { - applyAccountNameToChannelSection, - DEFAULT_ACCOUNT_ID, - migrateBaseNameToDefaultAccount, - normalizeAccountId, - normalizeSecretInputString, - type ChannelSetupAdapter, -} from "openclaw/plugin-sdk/setup"; +import { prepareScopedSetupConfig } from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { normalizeSecretInputString } from "../../../src/config/types.secrets.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; @@ -45,12 +41,12 @@ export function buildMatrixConfigUpdate( export const matrixSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ + prepareScopedSetupConfig({ cfg: cfg as CoreConfig, channelKey: channel, accountId, name, - }), + }) as CoreConfig, validateInput: ({ input }) => { if (input.useEnv) { return null; @@ -75,19 +71,13 @@ export const matrixSetupAdapter: ChannelSetupAdapter = { return null; }, applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ + const next = prepareScopedSetupConfig({ cfg: cfg as CoreConfig, channelKey: channel, accountId, name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; + migrateBaseName: true, + }) as CoreConfig; if (input.useEnv) { return { ...next, diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 212d81380f1..a94482b8d43 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -1,6 +1,6 @@ import { - applyAccountNameToChannelSection, patchScopedAccountConfig, + prepareScopedSetupConfig, } from "../../../src/channels/plugins/setup-helpers.js"; import { mergeAllowFromEntries, @@ -187,7 +187,7 @@ export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ + prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, @@ -208,7 +208,7 @@ export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { }, applyAccountConfig: ({ cfg, accountId, input }) => { const setupInput = input as NextcloudSetupInput; - const namedConfig = applyAccountNameToChannelSection({ + const namedConfig = prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, diff --git a/extensions/tlon/src/setup-core.ts b/extensions/tlon/src/setup-core.ts index ae95819af52..08d72f2ab28 100644 --- a/extensions/tlon/src/setup-core.ts +++ b/extensions/tlon/src/setup-core.ts @@ -1,12 +1,11 @@ import { - applyAccountNameToChannelSection, - DEFAULT_ACCOUNT_ID, - normalizeAccountId, patchScopedAccountConfig, - type ChannelSetupAdapter, - type ChannelSetupInput, - type OpenClawConfig, -} from "openclaw/plugin-sdk/setup"; + prepareScopedSetupConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { buildTlonAccountFields } from "./account-fields.js"; import { resolveTlonAccount } from "./types.js"; @@ -30,7 +29,7 @@ export function applyTlonSetupConfig(params: { }): OpenClawConfig { const { cfg, accountId, input } = params; const useDefault = accountId === DEFAULT_ACCOUNT_ID; - const namedConfig = applyAccountNameToChannelSection({ + const namedConfig = prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, @@ -70,7 +69,7 @@ export function applyTlonSetupConfig(params: { export const tlonSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ + prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, diff --git a/src/channels/plugins/setup-helpers.test.ts b/src/channels/plugins/setup-helpers.test.ts index c45e13a9d7f..2040271f540 100644 --- a/src/channels/plugins/setup-helpers.test.ts +++ b/src/channels/plugins/setup-helpers.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; -import { applySetupAccountConfigPatch, createPatchedAccountSetupAdapter } from "./setup-helpers.js"; +import { + applySetupAccountConfigPatch, + createPatchedAccountSetupAdapter, + prepareScopedSetupConfig, +} from "./setup-helpers.js"; function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; @@ -157,3 +161,43 @@ describe("createPatchedAccountSetupAdapter", () => { expect(next.channels?.whatsapp).not.toHaveProperty("authDir"); }); }); + +describe("prepareScopedSetupConfig", () => { + it("stores the name and migrates it for named accounts when requested", () => { + const next = prepareScopedSetupConfig({ + cfg: asConfig({ + channels: { + bluebubbles: { + name: "Personal", + }, + }, + }), + channelKey: "bluebubbles", + accountId: "Work Team", + name: "Work", + migrateBaseName: true, + }); + + expect(next.channels?.bluebubbles).toMatchObject({ + accounts: { + default: { name: "Personal" }, + "work-team": { name: "Work" }, + }, + }); + expect(next.channels?.bluebubbles).not.toHaveProperty("name"); + }); + + it("keeps the base shape for the default account when migration is disabled", () => { + const next = prepareScopedSetupConfig({ + cfg: asConfig({ channels: { irc: { enabled: true } } }), + channelKey: "irc", + accountId: DEFAULT_ACCOUNT_ID, + name: "Libera", + }); + + expect(next.channels?.irc).toMatchObject({ + enabled: true, + name: "Libera", + }); + }); +}); diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index d4f618e870f..0f7b3f8000b 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -122,6 +122,31 @@ export function migrateBaseNameToDefaultAccount(params: { } as OpenClawConfig; } +export function prepareScopedSetupConfig(params: { + cfg: OpenClawConfig; + channelKey: string; + accountId: string; + name?: string; + alwaysUseAccounts?: boolean; + migrateBaseName?: boolean; +}): OpenClawConfig { + const namedConfig = applyAccountNameToChannelSection({ + cfg: params.cfg, + channelKey: params.channelKey, + accountId: params.accountId, + name: params.name, + alwaysUseAccounts: params.alwaysUseAccounts, + }); + if (!params.migrateBaseName || normalizeAccountId(params.accountId) === DEFAULT_ACCOUNT_ID) { + return namedConfig; + } + return migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: params.channelKey, + alwaysUseAccounts: params.alwaysUseAccounts, + }); +} + export function applySetupAccountConfigPatch(params: { cfg: OpenClawConfig; channelKey: string; From 233ef3119031290e117ba8e45e3b2524944f12bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:54:04 +0000 Subject: [PATCH 074/128] refactor(setup): reuse scoped config prelude in patched adapters --- src/channels/plugins/setup-helpers.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index 0f7b3f8000b..cfbd58a8d4e 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -172,7 +172,7 @@ export function createPatchedAccountSetupAdapter(params: { return { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ + prepareScopedSetupConfig({ cfg, channelKey: params.channelKey, accountId, @@ -181,21 +181,14 @@ export function createPatchedAccountSetupAdapter(params: { }), validateInput: params.validateInput, applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ + const next = prepareScopedSetupConfig({ cfg, channelKey: params.channelKey, accountId, name: input.name, alwaysUseAccounts: params.alwaysUseAccounts, + migrateBaseName: !params.alwaysUseAccounts, }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: params.channelKey, - alwaysUseAccounts: params.alwaysUseAccounts, - }) - : namedConfig; const patch = params.buildPatch(input); return patchScopedAccountConfig({ cfg: next, From 6a27db0cd7f59d80fc5cfa8a76b9c65d8cdbce22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:56:17 +0000 Subject: [PATCH 075/128] refactor(outbound): share thread id normalization --- extensions/discord/src/channel.ts | 15 +-------------- extensions/slack/src/channel.ts | 15 +-------------- extensions/telegram/src/channel.ts | 15 +-------------- src/infra/outbound/outbound-session.ts | 17 ++--------------- src/infra/outbound/thread-id.test.ts | 20 ++++++++++++++++++++ src/infra/outbound/thread-id.ts | 13 +++++++++++++ 6 files changed, 38 insertions(+), 57 deletions(-) create mode 100644 src/infra/outbound/thread-id.test.ts create mode 100644 src/infra/outbound/thread-id.ts diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 68e12e1e78b..b598f004cf7 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -26,6 +26,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/discord"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +import { normalizeOutboundThreadId } from "../../../src/infra/outbound/thread-id.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; import { listDiscordAccountIds, @@ -196,20 +197,6 @@ function parseDiscordExplicitTarget(raw: string) { } } -function normalizeOutboundThreadId(value?: string | number | null): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - return undefined; - } - return String(Math.trunc(value)); - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function buildDiscordBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index e1c515576d9..74b283884a7 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -25,6 +25,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/slack"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +import { normalizeOutboundThreadId } from "../../../src/infra/outbound/thread-id.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listEnabledSlackAccounts, @@ -136,20 +137,6 @@ function parseSlackExplicitTarget(raw: string) { }; } -function normalizeOutboundThreadId(value?: string | number | null): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - return undefined; - } - return String(Math.trunc(value)); - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function buildSlackBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 45cd93cd9e5..797b60c85d8 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -31,6 +31,7 @@ import { type OutboundSendDeps, resolveOutboundSendDep, } from "../../../src/infra/outbound/send-deps.js"; +import { normalizeOutboundThreadId } from "../../../src/infra/outbound/thread-id.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; import { inspectTelegramAccount } from "./account-inspect.js"; import { @@ -185,20 +186,6 @@ function parseTelegramExplicitTarget(raw: string) { }; } -function normalizeOutboundThreadId(value?: string | number | null): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - return undefined; - } - return String(Math.trunc(value)); - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function buildTelegramBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index c8da99c5f66..a65e2da313e 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -8,6 +8,7 @@ import { buildAgentSessionKey, type RoutePeer } from "../../routing/resolve-rout import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import type { ResolvedMessagingTarget } from "./target-resolver.js"; +import { normalizeOutboundThreadId } from "./thread-id.js"; export type OutboundSessionRoute = { sessionKey: string; @@ -30,20 +31,6 @@ export type ResolveOutboundSessionRouteParams = { threadId?: string | number | null; }; -function normalizeThreadId(value?: string | number | null): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - return undefined; - } - return String(Math.trunc(value)); - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function stripProviderPrefix(raw: string, channel: string): string { const trimmed = raw.trim(); const lower = trimmed.toLowerCase(); @@ -240,7 +227,7 @@ function resolveMattermostSession( channel: "mattermost", peer: { kind: isUser ? "direct" : "channel", id: rawId }, }); - const threadId = normalizeThreadId(params.replyToId ?? params.threadId); + const threadId = normalizeOutboundThreadId(params.replyToId ?? params.threadId); const threadKeys = resolveThreadSessionKeys({ baseSessionKey, threadId, diff --git a/src/infra/outbound/thread-id.test.ts b/src/infra/outbound/thread-id.test.ts new file mode 100644 index 00000000000..a872c0d78d7 --- /dev/null +++ b/src/infra/outbound/thread-id.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { normalizeOutboundThreadId } from "./thread-id.js"; + +describe("normalizeOutboundThreadId", () => { + it("returns undefined for missing values", () => { + expect(normalizeOutboundThreadId()).toBeUndefined(); + expect(normalizeOutboundThreadId(null)).toBeUndefined(); + expect(normalizeOutboundThreadId(" ")).toBeUndefined(); + }); + + it("normalizes numbers and trims strings", () => { + expect(normalizeOutboundThreadId(123.9)).toBe("123"); + expect(normalizeOutboundThreadId(" 456 ")).toBe("456"); + }); + + it("drops non-finite numeric values", () => { + expect(normalizeOutboundThreadId(Number.NaN)).toBeUndefined(); + expect(normalizeOutboundThreadId(Number.POSITIVE_INFINITY)).toBeUndefined(); + }); +}); diff --git a/src/infra/outbound/thread-id.ts b/src/infra/outbound/thread-id.ts new file mode 100644 index 00000000000..287ce99d34a --- /dev/null +++ b/src/infra/outbound/thread-id.ts @@ -0,0 +1,13 @@ +export function normalizeOutboundThreadId(value?: string | number | null): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return undefined; + } + return String(Math.trunc(value)); + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} From 8357372cc788ca68a433e1e438d39030cb47596c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 03:58:33 +0000 Subject: [PATCH 076/128] refactor(slack): share setup token credential config --- extensions/slack/src/setup-core.ts | 130 +++++++++++++---------------- 1 file changed, 58 insertions(+), 72 deletions(-) diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index c024bed75d8..b53472c3ce9 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -79,6 +79,60 @@ type SlackSetupWizardHandlers = { resolveGroupAllowlist: (params: SlackGroupAllowlistResolverParams) => Promise; }; +function buildSlackTokenCredential(params: { + inputKey: "botToken" | "appToken"; + providerHint: "slack-bot" | "slack-app"; + credentialLabel: string; + preferredEnvVar: "SLACK_BOT_TOKEN" | "SLACK_APP_TOKEN"; + inputPrompt: string; +}): NonNullable[number] { + const configKey = params.inputKey; + return { + inputKey: params.inputKey, + providerHint: params.providerHint, + credentialLabel: params.credentialLabel, + preferredEnvVar: params.preferredEnvVar, + envPrompt: `${params.preferredEnvVar} detected. Use env var?`, + keepPrompt: `${params.credentialLabel} already configured. Keep it?`, + inputPrompt: params.inputPrompt, + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + const tokenValue = resolved[configKey]?.trim() || undefined; + const configuredValue = resolved.config[configKey]; + return { + accountConfigured: Boolean(tokenValue) || hasConfiguredSecretInput(configuredValue), + hasConfiguredValue: hasConfiguredSecretInput(configuredValue), + resolvedValue: tokenValue, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env[params.preferredEnvVar]?.trim() + : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + enableSlackAccount(cfg, accountId), + applySet: ({ + cfg, + accountId, + value, + }: { + cfg: OpenClawConfig; + accountId: string; + value: unknown; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { + enabled: true, + [configKey]: value, + }, + }), + }; +} + export function createSlackSetupWizardBase(handlers: SlackSetupWizardHandlers): ChannelSetupWizard { const slackDmPolicy: ChannelSetupDmPolicy = { label: "Slack", @@ -128,88 +182,20 @@ export function createSlackSetupWizardBase(handlers: SlackSetupWizardHandlers): apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), }, credentials: [ - { + buildSlackTokenCredential({ inputKey: "botToken", providerHint: "slack-bot", credentialLabel: "Slack bot token", preferredEnvVar: "SLACK_BOT_TOKEN", - envPrompt: "SLACK_BOT_TOKEN detected. Use env var?", - keepPrompt: "Slack bot token already configured. Keep it?", inputPrompt: "Enter Slack bot token (xoxb-...)", - allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { - const resolved = resolveSlackAccount({ cfg, accountId }); - return { - accountConfigured: - Boolean(resolved.botToken) || hasConfiguredSecretInput(resolved.config.botToken), - hasConfiguredValue: hasConfiguredSecretInput(resolved.config.botToken), - resolvedValue: resolved.botToken?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_BOT_TOKEN?.trim() : undefined, - }; - }, - applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => - enableSlackAccount(cfg, accountId), - applySet: ({ - cfg, - accountId, - value, - }: { - cfg: OpenClawConfig; - accountId: string; - value: unknown; - }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { - enabled: true, - botToken: value, - }, - }), - }, - { + }), + buildSlackTokenCredential({ inputKey: "appToken", providerHint: "slack-app", credentialLabel: "Slack app token", preferredEnvVar: "SLACK_APP_TOKEN", - envPrompt: "SLACK_APP_TOKEN detected. Use env var?", - keepPrompt: "Slack app token already configured. Keep it?", inputPrompt: "Enter Slack app token (xapp-...)", - allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { - const resolved = resolveSlackAccount({ cfg, accountId }); - return { - accountConfigured: - Boolean(resolved.appToken) || hasConfiguredSecretInput(resolved.config.appToken), - hasConfiguredValue: hasConfiguredSecretInput(resolved.config.appToken), - resolvedValue: resolved.appToken?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_APP_TOKEN?.trim() : undefined, - }; - }, - applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => - enableSlackAccount(cfg, accountId), - applySet: ({ - cfg, - accountId, - value, - }: { - cfg: OpenClawConfig; - accountId: string; - value: unknown; - }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { - enabled: true, - appToken: value, - }, - }), - }, + }), ], dmPolicy: slackDmPolicy, allowFrom: { From a20b64cd92716de349c5942bd7248e4d21f8b988 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:03:07 +0000 Subject: [PATCH 077/128] refactor(providers): share api-key catalog helper --- extensions/kilocode/index.ts | 19 +++---- extensions/modelstudio/index.ts | 24 +++----- extensions/moonshot/index.ts | 24 +++----- extensions/nvidia/index.ts | 19 +++---- extensions/qianfan/index.ts | 19 +++---- extensions/synthetic/index.ts | 19 +++---- extensions/together/index.ts | 19 +++---- extensions/venice/index.ts | 19 +++---- extensions/vercel-ai-gateway/index.ts | 19 +++---- extensions/xiaomi/index.ts | 19 +++---- src/plugins/provider-catalog.test.ts | 80 +++++++++++++++++++++++++++ src/plugins/provider-catalog.ts | 28 ++++++++++ 12 files changed, 180 insertions(+), 128 deletions(-) create mode 100644 src/plugins/provider-catalog.test.ts create mode 100644 src/plugins/provider-catalog.ts diff --git a/extensions/kilocode/index.ts b/extensions/kilocode/index.ts index 3d58bebbf84..7089d212628 100644 --- a/extensions/kilocode/index.ts +++ b/extensions/kilocode/index.ts @@ -5,6 +5,7 @@ import { } from "../../src/agents/pi-embedded-runner/proxy-stream-wrappers.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { applyKilocodeConfig, KILOCODE_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildKilocodeProviderWithDiscovery } from "./provider-catalog.js"; const PROVIDER_ID = "kilocode"; @@ -44,18 +45,12 @@ const kilocodePlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...(await buildKilocodeProviderWithDiscovery()), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildKilocodeProviderWithDiscovery, + }), }, capabilities: { geminiThoughtSignatureSanitization: true, diff --git a/extensions/modelstudio/index.ts b/extensions/modelstudio/index.ts index fd1cfd828af..e4dc27ee6df 100644 --- a/extensions/modelstudio/index.ts +++ b/extensions/modelstudio/index.ts @@ -1,5 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { applyModelStudioConfig, applyModelStudioConfigCn, @@ -78,22 +79,13 @@ const modelStudioPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; - const explicitBaseUrl = - typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; - return { - provider: { - ...buildModelStudioProvider(), - ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildModelStudioProvider, + allowExplicitBaseUrl: true, + }), }, }); }, diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 5ecaac45219..5ef777edcc4 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -9,6 +9,7 @@ import { } from "../../src/agents/tools/web-search-plugin-factory.js"; import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; import { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { @@ -75,22 +76,13 @@ const moonshotPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; - const explicitBaseUrl = - typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; - return { - provider: { - ...buildMoonshotProvider(), - ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildMoonshotProvider, + allowExplicitBaseUrl: true, + }), }, wrapStreamFn: (ctx) => { const thinkingType = resolveMoonshotThinkingType({ diff --git a/extensions/nvidia/index.ts b/extensions/nvidia/index.ts index 02df4f8e6a3..82b59e40a93 100644 --- a/extensions/nvidia/index.ts +++ b/extensions/nvidia/index.ts @@ -1,4 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildNvidiaProvider } from "./provider-catalog.js"; const PROVIDER_ID = "nvidia"; @@ -17,18 +18,12 @@ const nvidiaPlugin = { auth: [], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildNvidiaProvider(), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildNvidiaProvider, + }), }, }); }, diff --git a/extensions/qianfan/index.ts b/extensions/qianfan/index.ts index 6840c8623fa..04bd8429755 100644 --- a/extensions/qianfan/index.ts +++ b/extensions/qianfan/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildQianfanProvider } from "./provider-catalog.js"; const PROVIDER_ID = "qianfan"; @@ -40,18 +41,12 @@ const qianfanPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildQianfanProvider(), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildQianfanProvider, + }), }, }); }, diff --git a/extensions/synthetic/index.ts b/extensions/synthetic/index.ts index 9a100df052d..ed029dc7cce 100644 --- a/extensions/synthetic/index.ts +++ b/extensions/synthetic/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { applySyntheticConfig, SYNTHETIC_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildSyntheticProvider } from "./provider-catalog.js"; const PROVIDER_ID = "synthetic"; @@ -40,18 +41,12 @@ const syntheticPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildSyntheticProvider(), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildSyntheticProvider, + }), }, }); }, diff --git a/extensions/together/index.ts b/extensions/together/index.ts index 9a3a8df330c..a32031f0634 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { applyTogetherConfig, TOGETHER_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildTogetherProvider } from "./provider-catalog.js"; const PROVIDER_ID = "together"; @@ -40,18 +41,12 @@ const togetherPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildTogetherProvider(), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildTogetherProvider, + }), }, }); }, diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index 90b36a59f94..92ff17e6df5 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildVeniceProvider } from "./provider-catalog.js"; const PROVIDER_ID = "venice"; @@ -46,18 +47,12 @@ const venicePlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...(await buildVeniceProvider()), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildVeniceProvider, + }), }, }); }, diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index 31f3ff3db70..ea7c734f310 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildVercelAiGatewayProvider } from "./provider-catalog.js"; const PROVIDER_ID = "vercel-ai-gateway"; @@ -40,18 +41,12 @@ const vercelAiGatewayPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...(await buildVercelAiGatewayProvider()), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildVercelAiGatewayProvider, + }), }, }); }, diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index 05bcd699632..33eb6e47bf9 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -2,6 +2,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin import { PROVIDER_LABELS } from "../../src/infra/provider-usage.shared.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { applyXiaomiConfig, XIAOMI_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildXiaomiProvider } from "./provider-catalog.js"; const PROVIDER_ID = "xiaomi"; @@ -41,18 +42,12 @@ const xiaomiPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildXiaomiProvider(), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildXiaomiProvider, + }), }, resolveUsageAuth: async (ctx) => { const apiKey = ctx.resolveApiKeyFromConfigAndStore({ diff --git a/src/plugins/provider-catalog.test.ts b/src/plugins/provider-catalog.test.ts new file mode 100644 index 00000000000..3183f5ee016 --- /dev/null +++ b/src/plugins/provider-catalog.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { buildSingleProviderApiKeyCatalog } from "./provider-catalog.js"; +import type { ProviderCatalogContext } from "./types.js"; + +function createCatalogContext(params: { + config?: OpenClawConfig; + apiKeys?: Record; +}): ProviderCatalogContext { + return { + config: params.config ?? {}, + env: {}, + resolveProviderApiKey: (providerId) => ({ + apiKey: providerId ? params.apiKeys?.[providerId] : undefined, + }), + }; +} + +describe("buildSingleProviderApiKeyCatalog", () => { + it("returns null when api key is missing", async () => { + const result = await buildSingleProviderApiKeyCatalog({ + ctx: createCatalogContext({}), + providerId: "test-provider", + buildProvider: () => ({ api: "openai-completions", provider: "test-provider" }), + }); + + expect(result).toBeNull(); + }); + + it("adds api key to the built provider", async () => { + const result = await buildSingleProviderApiKeyCatalog({ + ctx: createCatalogContext({ + apiKeys: { "test-provider": "secret-key" }, + }), + providerId: "test-provider", + buildProvider: async () => ({ api: "openai-completions", provider: "test-provider" }), + }); + + expect(result).toEqual({ + provider: { + api: "openai-completions", + provider: "test-provider", + apiKey: "secret-key", + }, + }); + }); + + it("prefers explicit base url when allowed", async () => { + const result = await buildSingleProviderApiKeyCatalog({ + ctx: createCatalogContext({ + apiKeys: { "test-provider": "secret-key" }, + config: { + models: { + providers: { + "test-provider": { + baseUrl: " https://override.example/v1/ ", + }, + }, + }, + }, + }), + providerId: "test-provider", + buildProvider: () => ({ + api: "openai-completions", + provider: "test-provider", + baseUrl: "https://default.example/v1", + }), + allowExplicitBaseUrl: true, + }); + + expect(result).toEqual({ + provider: { + api: "openai-completions", + provider: "test-provider", + baseUrl: "https://override.example/v1/", + apiKey: "secret-key", + }, + }); + }); +}); diff --git a/src/plugins/provider-catalog.ts b/src/plugins/provider-catalog.ts new file mode 100644 index 00000000000..0974a9df59e --- /dev/null +++ b/src/plugins/provider-catalog.ts @@ -0,0 +1,28 @@ +import type { ModelProviderConfig } from "../config/types.js"; +import type { ProviderCatalogContext, ProviderCatalogResult } from "./types.js"; + +export async function buildSingleProviderApiKeyCatalog(params: { + ctx: ProviderCatalogContext; + providerId: string; + buildProvider: () => ModelProviderConfig | Promise; + allowExplicitBaseUrl?: boolean; +}): Promise { + const apiKey = params.ctx.resolveProviderApiKey(params.providerId).apiKey; + if (!apiKey) { + return null; + } + + const explicitProvider = params.allowExplicitBaseUrl + ? params.ctx.config.models?.providers?.[params.providerId] + : undefined; + const explicitBaseUrl = + typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; + + return { + provider: { + ...(await params.buildProvider()), + ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), + apiKey, + }, + }; +} From 0a6140acfa9a0ded0cb3b24a56d212825cb0650d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:04:12 +0000 Subject: [PATCH 078/128] refactor(providers): share catalog template matcher --- extensions/openai/shared.ts | 17 ++--------------- src/plugins/provider-catalog-metadata.ts | 17 +---------------- src/plugins/provider-catalog.test.ts | 15 ++++++++++++++- src/plugins/provider-catalog.ts | 16 ++++++++++++++++ 4 files changed, 33 insertions(+), 32 deletions(-) diff --git a/extensions/openai/shared.ts b/extensions/openai/shared.ts index 4e4c8c2d850..ad469a2f136 100644 --- a/extensions/openai/shared.ts +++ b/extensions/openai/shared.ts @@ -1,4 +1,5 @@ import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { findCatalogTemplate } from "../../src/plugins/provider-catalog.js"; import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, @@ -48,18 +49,4 @@ export function cloneFirstTemplateModel(params: { return undefined; } -export 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 { findCatalogTemplate }; diff --git a/src/plugins/provider-catalog-metadata.ts b/src/plugins/provider-catalog-metadata.ts index 123fef24289..5714861b219 100644 --- a/src/plugins/provider-catalog-metadata.ts +++ b/src/plugins/provider-catalog-metadata.ts @@ -1,4 +1,5 @@ import { normalizeProviderId } from "../agents/provider-id.js"; +import { findCatalogTemplate } from "./provider-catalog.js"; import type { ProviderAugmentModelCatalogContext, ProviderBuiltInModelSuppressionContext, @@ -9,22 +10,6 @@ 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, ) { diff --git a/src/plugins/provider-catalog.test.ts b/src/plugins/provider-catalog.test.ts index 3183f5ee016..c435e1d88b2 100644 --- a/src/plugins/provider-catalog.test.ts +++ b/src/plugins/provider-catalog.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { buildSingleProviderApiKeyCatalog } from "./provider-catalog.js"; +import { buildSingleProviderApiKeyCatalog, findCatalogTemplate } from "./provider-catalog.js"; import type { ProviderCatalogContext } from "./types.js"; function createCatalogContext(params: { @@ -17,6 +17,19 @@ function createCatalogContext(params: { } describe("buildSingleProviderApiKeyCatalog", () => { + it("matches provider templates case-insensitively", () => { + const result = findCatalogTemplate({ + entries: [ + { provider: "OpenAI", id: "gpt-5.2" }, + { provider: "other", id: "fallback" }, + ], + providerId: "openai", + templateIds: ["missing", "GPT-5.2"], + }); + + expect(result).toEqual({ provider: "OpenAI", id: "gpt-5.2" }); + }); + it("returns null when api key is missing", async () => { const result = await buildSingleProviderApiKeyCatalog({ ctx: createCatalogContext({}), diff --git a/src/plugins/provider-catalog.ts b/src/plugins/provider-catalog.ts index 0974a9df59e..3fcf2f39bcc 100644 --- a/src/plugins/provider-catalog.ts +++ b/src/plugins/provider-catalog.ts @@ -1,6 +1,22 @@ import type { ModelProviderConfig } from "../config/types.js"; import type { ProviderCatalogContext, ProviderCatalogResult } from "./types.js"; +export 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 async function buildSingleProviderApiKeyCatalog(params: { ctx: ProviderCatalogContext; providerId: string; From 39183746bab0dc1322a5256c808c8750e16aa753 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:05:24 +0000 Subject: [PATCH 079/128] refactor(providers): share paired api-key catalogs --- extensions/byteplus/index.ts | 22 ++++++++---------- extensions/volcengine/index.ts | 22 ++++++++---------- src/plugins/provider-catalog.test.ts | 34 +++++++++++++++++++++++++++- src/plugins/provider-catalog.ts | 20 ++++++++++++++++ 4 files changed, 73 insertions(+), 25 deletions(-) diff --git a/extensions/byteplus/index.ts b/extensions/byteplus/index.ts index d91fb87f1aa..7c6cf2f08fe 100644 --- a/extensions/byteplus/index.ts +++ b/extensions/byteplus/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { ensureModelAllowlistEntry } from "../../src/commands/model-allowlist.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildPairedProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildBytePlusCodingProvider, buildBytePlusProvider } from "./provider-catalog.js"; const PROVIDER_ID = "byteplus"; @@ -45,18 +46,15 @@ const byteplusPlugin = { ], catalog: { order: "paired", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - providers: { - byteplus: { ...buildBytePlusProvider(), apiKey }, - "byteplus-plan": { ...buildBytePlusCodingProvider(), apiKey }, - }, - }; - }, + run: (ctx) => + buildPairedProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProviders: () => ({ + byteplus: buildBytePlusProvider(), + "byteplus-plan": buildBytePlusCodingProvider(), + }), + }), }, }); }, diff --git a/extensions/volcengine/index.ts b/extensions/volcengine/index.ts index 4fadadb3608..f9e3fb72010 100644 --- a/extensions/volcengine/index.ts +++ b/extensions/volcengine/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { ensureModelAllowlistEntry } from "../../src/commands/model-allowlist.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildPairedProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildDoubaoCodingProvider, buildDoubaoProvider } from "./provider-catalog.js"; const PROVIDER_ID = "volcengine"; @@ -45,18 +46,15 @@ const volcenginePlugin = { ], catalog: { order: "paired", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - providers: { - volcengine: { ...buildDoubaoProvider(), apiKey }, - "volcengine-plan": { ...buildDoubaoCodingProvider(), apiKey }, - }, - }; - }, + run: (ctx) => + buildPairedProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProviders: () => ({ + volcengine: buildDoubaoProvider(), + "volcengine-plan": buildDoubaoCodingProvider(), + }), + }), }, }); }, diff --git a/src/plugins/provider-catalog.test.ts b/src/plugins/provider-catalog.test.ts index c435e1d88b2..e150d021a7b 100644 --- a/src/plugins/provider-catalog.test.ts +++ b/src/plugins/provider-catalog.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { buildSingleProviderApiKeyCatalog, findCatalogTemplate } from "./provider-catalog.js"; +import { + buildPairedProviderApiKeyCatalog, + buildSingleProviderApiKeyCatalog, + findCatalogTemplate, +} from "./provider-catalog.js"; import type { ProviderCatalogContext } from "./types.js"; function createCatalogContext(params: { @@ -90,4 +94,32 @@ describe("buildSingleProviderApiKeyCatalog", () => { }, }); }); + + it("adds api key to each paired provider", async () => { + const result = await buildPairedProviderApiKeyCatalog({ + ctx: createCatalogContext({ + apiKeys: { "test-provider": "secret-key" }, + }), + providerId: "test-provider", + buildProviders: async () => ({ + alpha: { api: "openai-completions", provider: "alpha" }, + beta: { api: "openai-completions", provider: "beta" }, + }), + }); + + expect(result).toEqual({ + providers: { + alpha: { + api: "openai-completions", + provider: "alpha", + apiKey: "secret-key", + }, + beta: { + api: "openai-completions", + provider: "beta", + apiKey: "secret-key", + }, + }, + }); + }); }); diff --git a/src/plugins/provider-catalog.ts b/src/plugins/provider-catalog.ts index 3fcf2f39bcc..1d357887c03 100644 --- a/src/plugins/provider-catalog.ts +++ b/src/plugins/provider-catalog.ts @@ -42,3 +42,23 @@ export async function buildSingleProviderApiKeyCatalog(params: { }, }; } + +export async function buildPairedProviderApiKeyCatalog(params: { + ctx: ProviderCatalogContext; + providerId: string; + buildProviders: () => + | Record + | Promise>; +}): Promise { + const apiKey = params.ctx.resolveProviderApiKey(params.providerId).apiKey; + if (!apiKey) { + return null; + } + + const providers = await params.buildProviders(); + return { + providers: Object.fromEntries( + Object.entries(providers).map(([id, provider]) => [id, { ...provider, apiKey }]), + ), + }; +} From 08d120e706c1d43144cccfa4efc355620f3e612a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:06:47 +0000 Subject: [PATCH 080/128] refactor(slack): share action adapter --- extensions/slack/src/channel.ts | 32 +++++++-------------------- src/channels/plugins/slack.actions.ts | 32 +++++++++++++++++++-------- 2 files changed, 31 insertions(+), 33 deletions(-) diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 74b283884a7..2980316a138 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -24,6 +24,7 @@ import { type ChannelPlugin, type OpenClawConfig, } from "openclaw/plugin-sdk/slack"; +import { createSlackActions } from "../../../src/channels/plugins/slack.actions.js"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { normalizeOutboundThreadId } from "../../../src/infra/outbound/thread-id.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; @@ -36,8 +37,6 @@ import { import { parseSlackBlocksInput } from "./blocks-input.js"; import { createSlackWebClient } from "./client.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; -import { handleSlackMessageAction } from "./message-action-dispatch.js"; -import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; import type { SlackProbe } from "./probe.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; @@ -319,6 +318,12 @@ const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, })); +const slackActions = createSlackActions("slack", { + invoke: () => async (action, cfg, toolContext) => + await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), + skipNormalizeChannelId: true, +}); + export const slackPlugin: ChannelPlugin = { ...createSlackPluginBase({ setupWizard: slackSetupWizard, @@ -506,28 +511,7 @@ export const slackPlugin: ChannelPlugin = { return resolved.map((entry) => toResolvedTarget(entry, entry.note)); }, }, - actions: { - listActions: ({ cfg }) => listSlackMessageActions(cfg), - getCapabilities: ({ cfg }) => { - const capabilities = new Set<"interactive" | "blocks">(); - if (listSlackMessageActions(cfg).includes("send")) { - capabilities.add("blocks"); - } - if (isSlackInteractiveRepliesEnabled({ cfg })) { - capabilities.add("interactive"); - } - return Array.from(capabilities); - }, - extractToolSend: ({ args }) => extractSlackToolSend(args), - handleAction: async (ctx) => - await handleSlackMessageAction({ - providerId: "slack", - ctx, - includeReadThreadId: true, - invoke: async (action, cfg, toolContext) => - await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), - }), - }, + actions: slackActions, outbound: { deliveryMode: "direct", chunker: null, diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index 7e74af7058d..df53d1ff0e0 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -6,9 +6,20 @@ import { resolveSlackChannelId, } from "../../plugin-sdk-internal/slack.js"; import { handleSlackMessageAction } from "../../plugin-sdk/slack-message-actions.js"; -import type { ChannelMessageActionAdapter } from "./types.js"; +import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js"; -export function createSlackActions(providerId: string): ChannelMessageActionAdapter { +type SlackActionAdapterOptions = { + includeReadThreadId?: boolean; + invoke?: ( + ctx: ChannelMessageActionContext, + ) => Parameters[0]["invoke"]; + skipNormalizeChannelId?: boolean; +}; + +export function createSlackActions( + providerId: string, + options?: SlackActionAdapterOptions, +): ChannelMessageActionAdapter { return { listActions: ({ cfg }) => listSlackMessageActions(cfg), getCapabilities: ({ cfg }) => { @@ -23,16 +34,19 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap }, extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => { - return await handleSlackMessageAction({ - providerId, - ctx, - normalizeChannelId: resolveSlackChannelId, - includeReadThreadId: true, - invoke: async (action, cfg, toolContext) => + const invoke = + options?.invoke?.(ctx) ?? + (async (action, cfg, toolContext) => await handleSlackAction(action, cfg, { ...(toolContext as SlackActionContext | undefined), mediaLocalRoots: ctx.mediaLocalRoots, - }), + })); + return await handleSlackMessageAction({ + providerId, + ctx, + normalizeChannelId: options?.skipNormalizeChannelId ? undefined : resolveSlackChannelId, + includeReadThreadId: options?.includeReadThreadId ?? true, + invoke, }); }, }; From 45cb02b1dd86c693f4d39c5f4dda96a0e148b6c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:07:28 +0000 Subject: [PATCH 081/128] refactor(plugins): share MCP server map extraction --- src/agents/cli-runner/bundle-mcp.ts | 27 ++------------------------- src/plugins/bundle-mcp.ts | 2 +- 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index 60e6149519c..96aeb867869 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -5,43 +5,20 @@ import type { OpenClawConfig } from "../../config/config.js"; import { applyMergePatch } from "../../config/merge-patch.js"; import type { CliBackendConfig } from "../../config/types.js"; import { + extractMcpServerMap, loadEnabledBundleMcpConfig, type BundleMcpConfig, - type BundleMcpServerConfig, } from "../../plugins/bundle-mcp.js"; -import { isRecord } from "../../utils.js"; type PreparedCliBundleMcpConfig = { backend: CliBackendConfig; cleanup?: () => Promise; }; -function extractServerMap(raw: unknown): Record { - if (!isRecord(raw)) { - return {}; - } - const nested = isRecord(raw.mcpServers) - ? raw.mcpServers - : isRecord(raw.servers) - ? raw.servers - : raw; - if (!isRecord(nested)) { - return {}; - } - const result: Record = {}; - for (const [serverName, serverRaw] of Object.entries(nested)) { - if (!isRecord(serverRaw)) { - continue; - } - result[serverName] = { ...serverRaw }; - } - return result; -} - async function readExternalMcpConfig(configPath: string): Promise { try { const raw = JSON.parse(await fs.readFile(configPath, "utf-8")) as unknown; - return { mcpServers: extractServerMap(raw) }; + return { mcpServers: extractMcpServerMap(raw) }; } catch { return { mcpServers: {} }; } diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index 6ce186384c7..62c10e59156 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -105,7 +105,7 @@ function resolveBundleMcpConfigPaths(params: { return mergeUniquePathLists(defaults, declared); } -function extractMcpServerMap(raw: unknown): Record { +export function extractMcpServerMap(raw: unknown): Record { if (!isRecord(raw)) { return {}; } From f4fa84aea7ae4570a81cda6d12af94fe8dbd46c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:13:38 -0700 Subject: [PATCH 082/128] feat(plugins): tighten media runtime integration --- .../discord/src/voice/manager.e2e.test.ts | 32 ++-------- extensions/discord/src/voice/manager.ts | 42 +++----------- extensions/whatsapp/src/setup-surface.ts | 1 + extensions/zalo/src/channel.runtime.ts | 4 +- src/plugins/contracts/registry.ts | 58 +++++++++---------- src/plugins/registry.ts | 33 +++-------- src/plugins/runtime/index.test.ts | 8 +++ vitest.e2e.config.ts | 2 +- 8 files changed, 63 insertions(+), 117 deletions(-) diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 17d21ff7414..73c6f249021 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -8,10 +8,7 @@ const { createAudioPlayerMock, resolveAgentRouteMock, agentCommandMock, - buildProviderRegistryMock, - createMediaAttachmentCacheMock, - normalizeMediaAttachmentsMock, - runCapabilityMock, + transcribeAudioFileMock, } = vi.hoisted(() => { type EventHandler = (...args: unknown[]) => unknown; type MockConnection = { @@ -68,14 +65,7 @@ const { })), resolveAgentRouteMock: vi.fn(() => ({ agentId: "agent-1", sessionKey: "discord:g1:c1" })), agentCommandMock: vi.fn(async (_opts?: unknown, _runtime?: unknown) => ({ payloads: [] })), - buildProviderRegistryMock: vi.fn(() => ({})), - createMediaAttachmentCacheMock: vi.fn(() => ({ - cleanup: vi.fn(async () => undefined), - })), - normalizeMediaAttachmentsMock: vi.fn(() => [{ kind: "audio", path: "/tmp/test.wav" }]), - runCapabilityMock: vi.fn(async () => ({ - outputs: [{ kind: "audio.transcription", text: "hello from voice" }], - })), + transcribeAudioFileMock: vi.fn(async () => ({ text: "hello from voice" })), }; }); @@ -103,11 +93,8 @@ vi.mock("../../../../src/commands/agent.js", () => ({ agentCommandFromIngress: agentCommandMock, })); -vi.mock("../../../../src/media-understanding/runner.js", () => ({ - buildProviderRegistry: buildProviderRegistryMock, - createMediaAttachmentCache: createMediaAttachmentCacheMock, - normalizeMediaAttachments: normalizeMediaAttachmentsMock, - runCapability: runCapabilityMock, +vi.mock("../../../../src/media-understanding/runtime.js", () => ({ + transcribeAudioFile: transcribeAudioFileMock, })); let managerModule: typeof import("./manager.js"); @@ -149,15 +136,8 @@ describe("DiscordVoiceManager", () => { resolveAgentRouteMock.mockClear(); agentCommandMock.mockReset(); agentCommandMock.mockResolvedValue({ payloads: [] }); - buildProviderRegistryMock.mockReset(); - buildProviderRegistryMock.mockReturnValue({}); - createMediaAttachmentCacheMock.mockClear(); - normalizeMediaAttachmentsMock.mockReset(); - normalizeMediaAttachmentsMock.mockReturnValue([{ kind: "audio", path: "/tmp/test.wav" }]); - runCapabilityMock.mockReset(); - runCapabilityMock.mockResolvedValue({ - outputs: [{ kind: "audio.transcription", text: "hello from voice" }], - }); + transcribeAudioFileMock.mockReset(); + transcribeAudioFileMock.mockResolvedValue({ text: "hello from voice" }); }); const createManager = ( diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 90c6c3bb1e6..a9f8d0fd721 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -17,7 +17,6 @@ import { type VoiceConnection, } from "@discordjs/voice"; import { resolveAgentDir } from "../../../../src/agents/agent-scope.js"; -import type { MsgContext } from "../../../../src/auto-reply/templating.js"; import { agentCommandFromIngress } from "../../../../src/commands/agent.js"; import type { OpenClawConfig } from "../../../../src/config/config.js"; import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; @@ -26,12 +25,7 @@ import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; import { formatErrorMessage } from "../../../../src/infra/errors.js"; import { resolvePreferredOpenClawTmpDir } from "../../../../src/infra/tmp-openclaw-dir.js"; import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; -import { - buildProviderRegistry, - createMediaAttachmentCache, - normalizeMediaAttachments, - runCapability, -} from "../../../../src/media-understanding/runner.js"; +import { transcribeAudioFile } from "../../../../src/media-understanding/runtime.js"; import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; import type { RuntimeEnv } from "../../../../src/runtime.js"; import { parseTtsDirectives } from "../../../../src/tts/tts-core.js"; @@ -236,33 +230,13 @@ async function transcribeAudio(params: { agentId: string; filePath: string; }): Promise { - const ctx: MsgContext = { - MediaPath: params.filePath, - MediaType: "audio/wav", - }; - const attachments = normalizeMediaAttachments(ctx); - if (attachments.length === 0) { - return undefined; - } - const cache = createMediaAttachmentCache(attachments); - const providerRegistry = buildProviderRegistry(); - try { - const result = await runCapability({ - capability: "audio", - cfg: params.cfg, - ctx, - attachments: cache, - media: attachments, - agentDir: resolveAgentDir(params.cfg, params.agentId), - providerRegistry, - config: params.cfg.tools?.media?.audio, - }); - const output = result.outputs.find((entry) => entry.kind === "audio.transcription"); - const text = output?.text?.trim(); - return text || undefined; - } finally { - await cache.cleanup(); - } + const result = await transcribeAudioFile({ + cfg: params.cfg, + filePath: params.filePath, + mime: "audio/wav", + agentDir: resolveAgentDir(params.cfg, params.agentId), + }); + return result.text?.trim() || undefined; } export class DiscordVoiceManager { diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 50a28d419cb..47e84de6860 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -9,6 +9,7 @@ import { pathExists, splitSetupEntries, setSetupChannelEnabled, + type DmPolicy, type OpenClawConfig, } from "../../../src/plugin-sdk-internal/setup.js"; import type { ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; diff --git a/extensions/zalo/src/channel.runtime.ts b/extensions/zalo/src/channel.runtime.ts index fc4488b5be8..a376d52b94e 100644 --- a/extensions/zalo/src/channel.runtime.ts +++ b/extensions/zalo/src/channel.runtime.ts @@ -41,7 +41,9 @@ export async function probeZaloAccount(params: { export async function startZaloGatewayAccount( ctx: Parameters< - NonNullable["startAccount"] + NonNullable< + NonNullable["startAccount"] + > >[0], ) { const account = ctx.account; diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 14dbb17262c..3c5cc8935c9 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -47,26 +47,20 @@ type RegistrablePlugin = { register: (api: ReturnType["api"]) => void; }; -type ProviderContractEntry = { +type CapabilityContractEntry = { pluginId: string; - provider: ProviderPlugin; + provider: T; }; -type WebSearchProviderContractEntry = { - pluginId: string; - provider: WebSearchProviderPlugin; +type ProviderContractEntry = CapabilityContractEntry; + +type WebSearchProviderContractEntry = CapabilityContractEntry & { credentialValue: unknown; }; -type SpeechProviderContractEntry = { - pluginId: string; - provider: SpeechProviderPlugin; -}; - -type MediaUnderstandingProviderContractEntry = { - pluginId: string; - provider: MediaUnderstandingProviderPlugin; -}; +type SpeechProviderContractEntry = CapabilityContractEntry; +type MediaUnderstandingProviderContractEntry = + CapabilityContractEntry; type PluginRegistrationContractEntry = { pluginId: string; @@ -138,15 +132,23 @@ function captureRegistrations(plugin: RegistrablePlugin) { return captured; } -export const providerContractRegistry: ProviderContractEntry[] = bundledProviderPlugins.flatMap( - (plugin) => { +function buildCapabilityContractRegistry(params: { + plugins: RegistrablePlugin[]; + select: (captured: ReturnType) => T[]; +}): CapabilityContractEntry[] { + return params.plugins.flatMap((plugin) => { const captured = captureRegistrations(plugin); - return captured.providers.map((provider) => ({ + return params.select(captured).map((provider) => ({ pluginId: plugin.id, provider, })); - }, -); + }); +} + +export const providerContractRegistry: ProviderContractEntry[] = buildCapabilityContractRegistry({ + plugins: bundledProviderPlugins, + select: (captured) => captured.providers, +}); export const webSearchProviderContractRegistry: WebSearchProviderContractEntry[] = bundledWebSearchPlugins.flatMap((plugin) => { @@ -159,21 +161,15 @@ export const webSearchProviderContractRegistry: WebSearchProviderContractEntry[] }); export const speechProviderContractRegistry: SpeechProviderContractEntry[] = - bundledSpeechPlugins.flatMap((plugin) => { - const captured = captureRegistrations(plugin); - return captured.speechProviders.map((provider) => ({ - pluginId: plugin.id, - provider, - })); + buildCapabilityContractRegistry({ + plugins: bundledSpeechPlugins, + select: (captured) => captured.speechProviders, }); export const mediaUnderstandingProviderContractRegistry: MediaUnderstandingProviderContractEntry[] = - bundledMediaUnderstandingPlugins.flatMap((plugin) => { - const captured = captureRegistrations(plugin); - return captured.mediaUnderstandingProviders.map((provider) => ({ - pluginId: plugin.id, - provider, - })); + buildCapabilityContractRegistry({ + plugins: bundledMediaUnderstandingPlugins, + select: (captured) => captured.mediaUnderstandingProviders, }); const bundledPluginRegistrationList = [ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 6ec51d889fc..c81c2253e0a 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -104,29 +104,20 @@ export type PluginProviderRegistration = { rootDir?: string; }; -export type PluginWebSearchProviderRegistration = { +type PluginOwnedProviderRegistration = { pluginId: string; pluginName?: string; - provider: WebSearchProviderPlugin; + provider: T; source: string; rootDir?: string; }; -export type PluginSpeechProviderRegistration = { - pluginId: string; - pluginName?: string; - provider: SpeechProviderPlugin; - source: string; - rootDir?: string; -}; - -export type PluginMediaUnderstandingProviderRegistration = { - pluginId: string; - pluginName?: string; - provider: MediaUnderstandingProviderPlugin; - source: string; - rootDir?: string; -}; +export type PluginSpeechProviderRegistration = + PluginOwnedProviderRegistration; +export type PluginMediaUnderstandingProviderRegistration = + PluginOwnedProviderRegistration; +export type PluginWebSearchProviderRegistration = + PluginOwnedProviderRegistration; export type PluginHookRegistration = { pluginId: string; @@ -576,13 +567,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const registerUniqueProviderLike = < T extends { id: string }, - R extends { - pluginId: string; - pluginName?: string; - provider: T; - source: string; - rootDir?: string; - }, + R extends PluginOwnedProviderRegistration, >(params: { record: PluginRecord; provider: T; diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index dfca1cfaf4a..9f7613881a5 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -55,6 +55,14 @@ describe("plugin runtime command execution", () => { expect(runtime.events.onSessionTranscriptUpdate).toBe(onSessionTranscriptUpdate); }); + it("exposes runtime.mediaUnderstanding helpers and keeps stt as an alias", () => { + const runtime = createPluginRuntime(); + expect(typeof runtime.mediaUnderstanding.runFile).toBe("function"); + expect(typeof runtime.mediaUnderstanding.describeImageFile).toBe("function"); + expect(typeof runtime.mediaUnderstanding.describeVideoFile).toBe("function"); + expect(runtime.mediaUnderstanding.transcribeAudioFile).toBe(runtime.stt.transcribeAudioFile); + }); + it("exposes runtime.system.requestHeartbeatNow", () => { const runtime = createPluginRuntime(); expect(runtime.system.requestHeartbeatNow).toBe(requestHeartbeatNow); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index b70d8c8eedb..67e7cada10e 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ pool: "forks", maxWorkers: e2eWorkers, silent: !verboseE2E, - include: ["test/**/*.e2e.test.ts", "src/**/*.e2e.test.ts"], + include: ["test/**/*.e2e.test.ts", "src/**/*.e2e.test.ts", "extensions/**/*.e2e.test.ts"], exclude, }, }); From afc0172cb1d8837755d4d642752775ac83668263 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:13:46 -0700 Subject: [PATCH 083/128] docs(plugins): add capability checklist template --- docs/tools/plugin.md | 59 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index c1dc9398f5c..0e9e831023c 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1386,6 +1386,65 @@ Recommended sequence: This is how OpenClaw stays opinionated without becoming hardcoded to one provider's worldview. +### Capability checklist + +When you add a new capability, the implementation should usually touch these +surfaces together: + +- core contract types in `src//types.ts` +- core runner/runtime helper in `src//runtime.ts` +- plugin API registration surface in `src/plugins/types.ts` +- plugin registry wiring in `src/plugins/registry.ts` +- plugin runtime exposure in `src/plugins/runtime/*` when feature/channel + plugins need to consume it +- capture/test helpers in `src/test-utils/plugin-registration.ts` +- ownership/contract assertions in `src/plugins/contracts/registry.ts` +- operator/plugin docs in `docs/` + +If one of those surfaces is missing, that is usually a sign the capability is +not fully integrated yet. + +### Capability template + +Minimal pattern: + +```ts +// core contract +export type VideoGenerationProviderPlugin = { + id: string; + label: string; + generateVideo: (req: VideoGenerationRequest) => Promise; +}; + +// plugin API +api.registerVideoGenerationProvider({ + id: "openai", + label: "OpenAI", + async generateVideo(req) { + return await generateOpenAiVideo(req); + }, +}); + +// shared runtime helper for feature/channel plugins +const clip = await api.runtime.videoGeneration.generateFile({ + prompt: "Show the robot walking through the lab.", + cfg, +}); +``` + +Contract test pattern: + +```ts +expect(findVideoGenerationProviderIdsForPlugin("openai")).toEqual(["openai"]); +``` + +That keeps the rule simple: + +- core owns the capability contract + orchestration +- vendor plugins own vendor implementations +- feature/channel plugins consume runtime helpers +- contract tests keep ownership explicit + Context engine plugins can also register a runtime-owned context manager: ```ts From 9ebe38b6e36b6128b194e17892e95920cafee42b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:13:56 -0700 Subject: [PATCH 084/128] refactor: untangle remaining plugin sdk boundaries --- .../acpx/src/test-utils/runtime-fixtures.ts | 2 +- extensions/anthropic/index.ts | 34 +- extensions/brave/index.ts | 5 +- extensions/byteplus/index.ts | 26 +- extensions/byteplus/provider-catalog.ts | 4 +- extensions/cloudflare-ai-gateway/index.ts | 29 +- extensions/cloudflare-ai-gateway/onboard.ts | 6 +- extensions/discord/src/account-inspect.ts | 4 +- extensions/discord/src/accounts.ts | 6 +- .../src/actions/handle-action.guild-admin.ts | 8 +- .../discord/src/actions/handle-action.ts | 14 +- extensions/discord/src/api.ts | 8 +- extensions/discord/src/audit.ts | 9 +- extensions/discord/src/channel-actions.ts | 6 +- extensions/discord/src/channel.setup.ts | 45 ++- extensions/discord/src/channel.ts | 64 +++- extensions/discord/src/chunk.ts | 2 +- extensions/discord/src/client.ts | 8 +- extensions/discord/src/directory-cache.ts | 2 +- extensions/discord/src/directory-live.ts | 4 +- extensions/discord/src/draft-chunking.ts | 8 +- extensions/discord/src/draft-stream.ts | 2 +- extensions/discord/src/exec-approvals.ts | 6 +- extensions/discord/src/gateway-logging.ts | 4 +- .../src/monitor.tool-result.test-harness.ts | 12 +- .../discord/src/monitor/agent-components.ts | 56 +-- extensions/discord/src/monitor/allow-list.ts | 6 +- .../discord/src/monitor/auto-presence.ts | 6 +- extensions/discord/src/monitor/commands.ts | 2 +- .../discord/src/monitor/dm-command-auth.ts | 4 +- .../src/monitor/dm-command-decision.ts | 4 +- .../discord/src/monitor/exec-approvals.ts | 34 +- .../discord/src/monitor/gateway-plugin.ts | 8 +- .../discord/src/monitor/inbound-context.ts | 2 +- .../discord/src/monitor/inbound-worker.ts | 6 +- extensions/discord/src/monitor/listeners.ts | 22 +- .../message-handler.module-test-helpers.ts | 2 +- .../message-handler.preflight.test-helpers.ts | 4 +- .../src/monitor/message-handler.preflight.ts | 49 ++- .../message-handler.preflight.types.ts | 16 +- .../src/monitor/message-handler.process.ts | 64 ++-- .../monitor/message-handler.test-helpers.ts | 2 +- .../discord/src/monitor/message-handler.ts | 6 +- .../discord/src/monitor/message-utils.ts | 10 +- .../src/monitor/model-picker-preferences.ts | 8 +- .../src/monitor/model-picker.test-utils.ts | 2 +- .../discord/src/monitor/model-picker.ts | 6 +- .../src/monitor/native-command-context.ts | 4 +- .../discord/src/monitor/native-command.ts | 46 +-- .../discord/src/monitor/preflight-audio.ts | 7 +- extensions/discord/src/monitor/presence.ts | 2 +- .../discord/src/monitor/provider.allowlist.ts | 10 +- .../discord/src/monitor/provider.lifecycle.ts | 8 +- extensions/discord/src/monitor/provider.ts | 46 +-- .../discord/src/monitor/reply-delivery.ts | 22 +- extensions/discord/src/monitor/rest-fetch.ts | 6 +- .../discord/src/monitor/route-resolution.ts | 6 +- .../src/monitor/thread-bindings.config.ts | 6 +- .../monitor/thread-bindings.discord-api.ts | 4 +- .../src/monitor/thread-bindings.lifecycle.ts | 9 +- .../src/monitor/thread-bindings.manager.ts | 13 +- .../src/monitor/thread-bindings.messages.ts | 2 +- .../src/monitor/thread-bindings.persona.ts | 2 +- .../src/monitor/thread-bindings.state.ts | 9 +- .../src/monitor/thread-session-close.ts | 4 +- extensions/discord/src/monitor/threading.ts | 10 +- extensions/discord/src/outbound-adapter.ts | 10 +- extensions/discord/src/plugin-shared.ts | 6 +- extensions/discord/src/pluralkit.ts | 2 +- extensions/discord/src/probe.ts | 6 +- extensions/discord/src/runtime.ts | 6 +- extensions/discord/src/send.components.ts | 4 +- extensions/discord/src/send.outbound.ts | 22 +- extensions/discord/src/send.reactions.ts | 2 +- extensions/discord/src/send.shared.ts | 10 +- extensions/discord/src/send.test-harness.ts | 2 +- extensions/discord/src/send.types.ts | 4 +- .../discord/src/session-key-normalization.ts | 4 +- extensions/discord/src/setup-core.ts | 214 ++++++----- extensions/discord/src/setup-surface.ts | 257 +++++++++---- extensions/discord/src/shared-interactive.ts | 4 +- extensions/discord/src/status-issues.ts | 4 +- extensions/discord/src/subagent-hooks.ts | 2 +- extensions/discord/src/targets.ts | 4 +- extensions/discord/src/token.ts | 8 +- extensions/discord/src/ui.ts | 2 +- extensions/discord/src/voice-message.ts | 10 +- extensions/discord/src/voice/command.ts | 8 +- extensions/discord/src/voice/manager.ts | 72 ++-- extensions/elevenlabs/index.ts | 2 +- extensions/feishu/src/bot.ts | 14 +- extensions/feishu/src/media.ts | 2 +- extensions/feishu/src/thread-bindings.ts | 15 +- extensions/firecrawl/index.ts | 8 +- extensions/firecrawl/src/config.ts | 6 +- extensions/firecrawl/src/firecrawl-client.ts | 10 +- .../firecrawl/src/firecrawl-scrape-tool.ts | 6 +- .../src/firecrawl-search-provider.ts | 2 +- .../firecrawl/src/firecrawl-search-tool.ts | 4 +- extensions/github-copilot/index.ts | 12 +- extensions/github-copilot/token.ts | 4 +- extensions/github-copilot/usage.ts | 8 +- extensions/google/gemini-cli-provider.ts | 4 +- extensions/google/index.ts | 15 +- extensions/google/oauth.flow.ts | 2 +- extensions/google/oauth.http.ts | 2 +- extensions/google/provider-models.ts | 4 +- extensions/huggingface/index.ts | 2 +- extensions/huggingface/onboard.ts | 6 +- extensions/huggingface/provider-catalog.ts | 4 +- extensions/imessage/src/accounts.ts | 6 +- extensions/imessage/src/channel.setup.ts | 99 ++++- extensions/imessage/src/channel.ts | 99 ++++- extensions/imessage/src/client.ts | 4 +- extensions/imessage/src/monitor/deliver.ts | 12 +- .../src/monitor/inbound-processing.ts | 35 +- .../imessage/src/monitor/monitor-provider.ts | 52 +-- .../imessage/src/monitor/reflection-guard.ts | 2 +- extensions/imessage/src/monitor/runtime.ts | 4 +- .../imessage/src/monitor/sanitize-outbound.ts | 2 +- extensions/imessage/src/monitor/types.ts | 4 +- extensions/imessage/src/outbound-adapter.ts | 7 +- extensions/imessage/src/plugin-shared.ts | 2 +- extensions/imessage/src/probe.ts | 10 +- extensions/imessage/src/runtime.ts | 6 +- extensions/imessage/src/send.ts | 10 +- extensions/imessage/src/setup-core.ts | 119 ++++--- extensions/imessage/src/setup-surface.ts | 151 ++++++-- .../imessage/src/target-parsing-helpers.ts | 2 +- extensions/imessage/src/targets.ts | 2 +- extensions/irc/src/setup-core.ts | 18 +- extensions/irc/src/setup-surface.ts | 17 +- extensions/kilocode/index.ts | 4 +- extensions/kilocode/onboard.ts | 9 +- extensions/kilocode/provider-catalog.ts | 6 +- extensions/kimi-coding/index.ts | 75 ++-- extensions/kimi-coding/onboard.ts | 40 +-- extensions/kimi-coding/provider-catalog.ts | 2 +- extensions/line/src/channel.setup.ts | 2 +- extensions/line/src/setup-core.ts | 9 +- extensions/line/src/setup-surface.ts | 12 +- extensions/matrix/src/outbound.ts | 2 +- extensions/mattermost/src/setup-core.ts | 52 ++- extensions/mattermost/src/setup-surface.ts | 4 +- extensions/microsoft/index.ts | 2 +- extensions/minimax/index.ts | 15 +- extensions/minimax/onboard.ts | 14 +- extensions/minimax/provider-catalog.ts | 5 +- extensions/mistral/index.ts | 4 +- extensions/mistral/onboard.ts | 15 +- extensions/modelstudio/index.ts | 26 +- extensions/modelstudio/onboard.ts | 12 +- extensions/modelstudio/provider-catalog.ts | 5 +- extensions/moonshot/index.ts | 51 +-- extensions/moonshot/onboard.ts | 4 +- extensions/moonshot/provider-catalog.ts | 2 +- extensions/msteams/src/outbound.ts | 2 +- extensions/nextcloud-talk/src/setup-core.ts | 34 +- .../nextcloud-talk/src/setup-surface.ts | 105 +++++- extensions/nostr/src/setup-surface.ts | 18 +- extensions/nvidia/provider-catalog.ts | 2 +- extensions/ollama/index.ts | 3 +- extensions/openai/index.ts | 4 +- extensions/openai/openai-codex-catalog.ts | 2 +- extensions/openai/openai-codex-provider.ts | 24 +- extensions/openai/openai-provider.ts | 10 +- extensions/openai/shared.ts | 21 +- extensions/opencode-go/index.ts | 4 +- extensions/opencode-go/onboard.ts | 8 +- extensions/opencode/index.ts | 4 +- extensions/opencode/onboard.ts | 8 +- extensions/openrouter/index.ts | 8 +- extensions/openrouter/onboard.ts | 6 +- extensions/openrouter/provider-catalog.ts | 2 +- extensions/perplexity/index.ts | 5 +- extensions/qianfan/index.ts | 2 +- extensions/qianfan/onboard.ts | 6 +- extensions/qianfan/provider-catalog.ts | 2 +- extensions/qwen-portal-auth/index.ts | 6 +- .../qwen-portal-auth/provider-catalog.ts | 5 +- extensions/sglang/index.ts | 12 +- extensions/signal/src/accounts.ts | 6 +- extensions/signal/src/channel.setup.ts | 95 ++++- extensions/signal/src/channel.ts | 95 ++++- extensions/signal/src/client.ts | 6 +- extensions/signal/src/daemon.ts | 2 +- extensions/signal/src/format.ts | 4 +- extensions/signal/src/identity.ts | 4 +- .../src/monitor.tool-result.test-harness.ts | 20 +- extensions/signal/src/monitor.ts | 37 +- .../signal/src/monitor/access-policy.ts | 6 +- .../signal/src/monitor/event-handler.ts | 57 ++- .../signal/src/monitor/event-handler.types.ts | 10 +- extensions/signal/src/outbound-adapter.ts | 13 +- extensions/signal/src/plugin-shared.ts | 4 +- extensions/signal/src/probe.ts | 2 +- extensions/signal/src/reaction-level.ts | 4 +- extensions/signal/src/rpc-context.ts | 2 +- extensions/signal/src/runtime.ts | 6 +- extensions/signal/src/send-reactions.ts | 4 +- extensions/signal/src/send.ts | 8 +- extensions/signal/src/setup-core.ts | 141 +++++--- extensions/signal/src/setup-surface.ts | 169 +++++++-- extensions/signal/src/sse-reconnect.ts | 8 +- extensions/slack/src/account-inspect.ts | 4 +- .../slack/src/account-surface-fields.ts | 2 +- extensions/slack/src/accounts.ts | 6 +- extensions/slack/src/actions.ts | 4 +- extensions/slack/src/blocks-render.ts | 4 +- extensions/slack/src/blocks.test-helpers.ts | 2 +- extensions/slack/src/channel-migration.ts | 6 +- extensions/slack/src/channel.setup.ts | 72 +++- extensions/slack/src/channel.ts | 127 +++++-- extensions/slack/src/directory-live.ts | 4 +- extensions/slack/src/draft-stream.ts | 2 +- extensions/slack/src/format.ts | 10 +- extensions/slack/src/interactive-replies.ts | 2 +- .../slack/src/message-action-dispatch.ts | 2 +- extensions/slack/src/message-actions.ts | 6 +- extensions/slack/src/monitor.test-helpers.ts | 12 +- extensions/slack/src/monitor/allow-list.ts | 4 +- extensions/slack/src/monitor/auth.ts | 2 +- .../slack/src/monitor/channel-config.ts | 4 +- extensions/slack/src/monitor/commands.ts | 2 +- extensions/slack/src/monitor/context.ts | 22 +- extensions/slack/src/monitor/dm-auth.ts | 6 +- .../slack/src/monitor/events/channels.ts | 8 +- .../events/interactions.block-actions.ts | 6 +- .../src/monitor/events/interactions.modal.ts | 2 +- .../slack/src/monitor/events/members.ts | 4 +- .../slack/src/monitor/events/messages.ts | 4 +- extensions/slack/src/monitor/events/pins.ts | 4 +- .../slack/src/monitor/events/reactions.ts | 4 +- .../monitor/events/system-event-context.ts | 2 +- .../src/monitor/external-arg-menu-store.ts | 2 +- extensions/slack/src/monitor/media.ts | 8 +- .../slack/src/monitor/message-handler.ts | 2 +- .../src/monitor/message-handler/dispatch.ts | 26 +- .../message-handler/prepare-content.ts | 2 +- .../message-handler/prepare-thread-context.ts | 8 +- .../message-handler/prepare.test-helpers.ts | 4 +- .../src/monitor/message-handler/prepare.ts | 55 ++- .../src/monitor/message-handler/types.ts | 4 +- extensions/slack/src/monitor/provider.ts | 30 +- extensions/slack/src/monitor/replies.ts | 14 +- extensions/slack/src/monitor/room-context.ts | 2 +- .../src/monitor/slash-commands.runtime.ts | 2 +- .../src/monitor/slash-dispatch.runtime.ts | 16 +- .../monitor/slash-skill-commands.runtime.ts | 2 +- .../slack/src/monitor/slash.test-harness.ts | 14 +- extensions/slack/src/monitor/slash.ts | 17 +- .../slack/src/monitor/thread-resolution.ts | 4 +- extensions/slack/src/monitor/types.ts | 4 +- extensions/slack/src/outbound-adapter.ts | 12 +- extensions/slack/src/plugin-shared.ts | 6 +- extensions/slack/src/probe.ts | 4 +- extensions/slack/src/runtime.ts | 6 +- extensions/slack/src/scopes.ts | 2 +- extensions/slack/src/send.ts | 18 +- extensions/slack/src/sent-thread-cache.ts | 2 +- extensions/slack/src/setup-core.ts | 337 ++++++++++-------- extensions/slack/src/setup-surface.ts | 284 ++++++++++++--- extensions/slack/src/stream-mode.ts | 2 +- extensions/slack/src/streaming.ts | 2 +- extensions/slack/src/targets.ts | 2 +- .../slack/src/threading-tool-context.ts | 4 +- extensions/slack/src/threading.ts | 2 +- extensions/slack/src/token.ts | 2 +- extensions/synthetic/index.ts | 2 +- extensions/synthetic/onboard.ts | 6 +- extensions/synthetic/provider-catalog.ts | 4 +- extensions/talk-voice/index.ts | 4 +- extensions/telegram/src/account-inspect.ts | 14 +- extensions/telegram/src/accounts.ts | 29 +- extensions/telegram/src/api-logging.ts | 8 +- extensions/telegram/src/approval-buttons.ts | 2 +- .../telegram/src/audit-membership-runtime.ts | 4 +- extensions/telegram/src/audit.ts | 4 +- extensions/telegram/src/bot-access.ts | 6 +- extensions/telegram/src/bot-handlers.ts | 60 ++-- .../telegram/src/bot-message-context.body.ts | 38 +- .../src/bot-message-context.session.ts | 40 +-- .../telegram/src/bot-message-context.ts | 22 +- .../telegram/src/bot-message-context.types.ts | 6 +- .../telegram/src/bot-message-dispatch.ts | 34 +- extensions/telegram/src/bot-message.ts | 8 +- .../telegram/src/bot-native-command-menu.ts | 8 +- .../src/bot-native-commands.test-helpers.ts | 32 +- .../telegram/src/bot-native-commands.ts | 76 ++-- extensions/telegram/src/bot-updates.ts | 2 +- .../bot.create-telegram-bot.test-harness.ts | 26 +- .../telegram/src/bot.media.e2e-harness.ts | 18 +- .../telegram/src/bot.media.test-utils.ts | 4 +- extensions/telegram/src/bot.ts | 31 +- .../telegram/src/bot/delivery.replies.ts | 29 +- .../src/bot/delivery.resolve-media.ts | 10 +- extensions/telegram/src/bot/delivery.send.ts | 4 +- extensions/telegram/src/bot/helpers.ts | 10 +- .../telegram/src/bot/reply-threading.ts | 2 +- extensions/telegram/src/button-types.ts | 4 +- extensions/telegram/src/channel-actions.ts | 17 +- extensions/telegram/src/channel.setup.ts | 71 +++- extensions/telegram/src/channel.ts | 107 ++++-- extensions/telegram/src/conversation-route.ts | 14 +- extensions/telegram/src/dm-access.ts | 8 +- extensions/telegram/src/draft-chunking.ts | 8 +- extensions/telegram/src/draft-stream.ts | 4 +- .../telegram/src/exec-approvals-handler.ts | 27 +- extensions/telegram/src/exec-approvals.ts | 8 +- extensions/telegram/src/fetch.ts | 10 +- extensions/telegram/src/format.ts | 6 +- extensions/telegram/src/group-access.ts | 10 +- .../telegram/src/group-config-helpers.ts | 2 +- extensions/telegram/src/group-migration.ts | 6 +- extensions/telegram/src/inline-buttons.ts | 4 +- .../src/lane-delivery-text-deliverer.ts | 2 +- extensions/telegram/src/monitor.ts | 14 +- extensions/telegram/src/network-config.ts | 6 +- extensions/telegram/src/network-errors.ts | 2 +- extensions/telegram/src/outbound-adapter.ts | 13 +- extensions/telegram/src/plugin-shared.ts | 9 +- extensions/telegram/src/polling-session.ts | 6 +- extensions/telegram/src/probe.ts | 6 +- extensions/telegram/src/proxy.ts | 2 +- extensions/telegram/src/reaction-level.ts | 4 +- .../src/reasoning-lane-coordinator.ts | 8 +- extensions/telegram/src/runtime.ts | 6 +- extensions/telegram/src/send.test-harness.ts | 6 +- extensions/telegram/src/send.ts | 28 +- .../src/sendchataction-401-backoff.ts | 6 +- extensions/telegram/src/sent-message-cache.ts | 2 +- extensions/telegram/src/sequential-key.ts | 4 +- extensions/telegram/src/setup-core.ts | 185 ++++------ extensions/telegram/src/setup-surface.ts | 118 +++++- extensions/telegram/src/status-issues.ts | 4 +- .../telegram/src/status-reaction-variants.ts | 5 +- extensions/telegram/src/sticker-cache.ts | 29 +- extensions/telegram/src/target-writeback.ts | 15 +- extensions/telegram/src/thread-bindings.ts | 16 +- extensions/telegram/src/token.ts | 12 +- .../telegram/src/update-offset-store.ts | 4 +- extensions/telegram/src/voice.ts | 2 +- extensions/telegram/src/webhook.ts | 14 +- extensions/test-utils/directory.ts | 2 +- extensions/test-utils/plugin-api.ts | 2 +- extensions/test-utils/plugin-runtime-mock.ts | 2 +- extensions/together/index.ts | 2 +- extensions/together/onboard.ts | 6 +- extensions/together/provider-catalog.ts | 4 +- extensions/twitch/src/plugin.ts | 2 +- extensions/venice/index.ts | 2 +- extensions/venice/onboard.ts | 6 +- extensions/venice/provider-catalog.ts | 7 +- extensions/vercel-ai-gateway/index.ts | 2 +- extensions/vercel-ai-gateway/onboard.ts | 6 +- .../vercel-ai-gateway/provider-catalog.ts | 4 +- extensions/vllm/index.ts | 12 +- extensions/volcengine/index.ts | 26 +- extensions/volcengine/provider-catalog.ts | 4 +- extensions/whatsapp/src/accounts.ts | 12 +- extensions/whatsapp/src/active-listener.ts | 6 +- extensions/whatsapp/src/agent-tools-login.ts | 2 +- extensions/whatsapp/src/auth-store.ts | 16 +- extensions/whatsapp/src/auto-reply.impl.ts | 4 +- .../whatsapp/src/auto-reply.test-harness.ts | 8 +- .../whatsapp/src/auto-reply/deliver-reply.ts | 14 +- .../src/auto-reply/heartbeat-runner.ts | 35 +- extensions/whatsapp/src/auto-reply/loggers.ts | 2 +- .../whatsapp/src/auto-reply/mentions.ts | 9 +- extensions/whatsapp/src/auto-reply/monitor.ts | 30 +- .../src/auto-reply/monitor/ack-reaction.ts | 6 +- .../src/auto-reply/monitor/broadcast.ts | 11 +- .../auto-reply/monitor/group-activation.ts | 8 +- .../src/auto-reply/monitor/group-gating.ts | 12 +- .../src/auto-reply/monitor/group-members.ts | 2 +- .../src/auto-reply/monitor/last-route.ts | 6 +- .../src/auto-reply/monitor/message-line.ts | 6 +- .../src/auto-reply/monitor/on-message.ts | 16 +- .../whatsapp/src/auto-reply/monitor/peer.ts | 2 +- .../src/auto-reply/monitor/process-message.ts | 42 +-- .../src/auto-reply/session-snapshot.ts | 6 +- extensions/whatsapp/src/channel.setup.ts | 159 ++++++++- extensions/whatsapp/src/channel.ts | 168 +++++++-- .../inbound/access-control.test-harness.ts | 6 +- .../whatsapp/src/inbound/access-control.ts | 14 +- extensions/whatsapp/src/inbound/dedupe.ts | 2 +- extensions/whatsapp/src/inbound/extract.ts | 6 +- extensions/whatsapp/src/inbound/media.ts | 2 +- extensions/whatsapp/src/inbound/monitor.ts | 16 +- extensions/whatsapp/src/inbound/send-api.ts | 4 +- extensions/whatsapp/src/inbound/types.ts | 2 +- extensions/whatsapp/src/login-qr.ts | 8 +- extensions/whatsapp/src/login.ts | 10 +- extensions/whatsapp/src/media.ts | 18 +- .../src/monitor-inbox.test-harness.ts | 12 +- extensions/whatsapp/src/normalize.ts | 2 +- extensions/whatsapp/src/outbound-adapter.ts | 12 +- extensions/whatsapp/src/plugin-shared.ts | 2 +- extensions/whatsapp/src/qr-image.ts | 2 +- extensions/whatsapp/src/reconnect.ts | 8 +- extensions/whatsapp/src/runtime.ts | 6 +- extensions/whatsapp/src/send.ts | 20 +- extensions/whatsapp/src/session.ts | 10 +- extensions/whatsapp/src/setup-core.ts | 58 ++- extensions/whatsapp/src/setup-surface.ts | 6 +- extensions/whatsapp/src/status-issues.ts | 6 +- extensions/whatsapp/src/test-helpers.ts | 10 +- extensions/xai/index.ts | 9 +- extensions/xai/onboard.ts | 15 +- extensions/xiaomi/index.ts | 4 +- extensions/xiaomi/onboard.ts | 4 +- extensions/xiaomi/provider-catalog.ts | 2 +- extensions/zai/detect.ts | 2 +- extensions/zai/index.ts | 25 +- extensions/zai/onboard.ts | 15 +- package.json | 120 +++++++ scripts/lib/plugin-sdk-entrypoints.json | 32 +- src/agents/pi-embedded-runner/compact.ts | 4 +- src/agents/pi-embedded-runner/run/attempt.ts | 4 +- src/agents/tools/discord-actions-guild.ts | 4 +- src/agents/tools/discord-actions-messaging.ts | 14 +- .../tools/discord-actions-moderation.ts | 2 +- src/agents/tools/discord-actions-presence.ts | 2 +- src/agents/tools/discord-actions.ts | 2 +- src/agents/tools/slack-actions.ts | 4 +- src/agents/tools/telegram-actions.ts | 15 +- src/agents/tools/whatsapp-actions.ts | 2 +- src/agents/tools/whatsapp-target-auth.ts | 2 +- src/auto-reply/reply/commands-approve.ts | 2 +- src/auto-reply/reply/commands-models.ts | 2 +- .../reply/directive-handling.model.ts | 2 +- src/auto-reply/templating.ts | 2 +- src/channel-web.ts | 16 +- src/channels/plugins/actions/discord.ts | 2 +- src/channels/plugins/actions/signal.ts | 2 +- src/channels/plugins/actions/telegram.ts | 2 +- .../plugins/agent-tools/whatsapp-login.ts | 2 +- src/channels/plugins/group-mentions.ts | 2 +- src/channels/plugins/slack.actions.ts | 36 +- ...ad-only-account-inspect.discord.runtime.ts | 4 +- ...read-only-account-inspect.slack.runtime.ts | 4 +- ...d-only-account-inspect.telegram.runtime.ts | 4 +- src/cli/deps.ts | 14 +- src/commands/doctor-config-flow.ts | 2 +- src/config/plugin-auto-enable.ts | 2 +- src/config/schema.help.ts | 2 +- .../explicit-session-key-normalization.ts | 2 +- src/config/types.discord.ts | 2 +- src/cron/isolated-agent/delivery-target.ts | 2 +- src/gateway/server-http.ts | 2 +- src/infra/state-migrations.ts | 2 +- src/plugin-sdk/account-resolution.ts | 13 + src/plugin-sdk/acp-runtime.ts | 6 + src/plugin-sdk/agent-runtime.ts | 28 ++ src/plugin-sdk/channel-config-helpers.ts | 16 + src/plugin-sdk/channel-runtime.ts | 53 +++ src/plugin-sdk/cli-runtime.ts | 6 + src/plugin-sdk/config-runtime.ts | 42 +++ src/plugin-sdk/conversation-runtime.ts | 41 +++ src/plugin-sdk/discord.ts | 86 +++++ src/plugin-sdk/gateway-runtime.ts | 6 + src/plugin-sdk/hook-runtime.ts | 5 + src/plugin-sdk/imessage.ts | 1 + src/plugin-sdk/infra-runtime.ts | 39 ++ src/plugin-sdk/json-store.ts | 7 + src/plugin-sdk/media-runtime.ts | 21 ++ src/plugin-sdk/plugin-runtime.ts | 6 + src/plugin-sdk/process-runtime.ts | 3 + src/plugin-sdk/provider-auth.ts | 43 +++ src/plugin-sdk/provider-models.ts | 86 +++++ src/plugin-sdk/provider-onboard.ts | 16 + src/plugin-sdk/provider-stream.ts | 17 + src/plugin-sdk/provider-usage.ts | 21 ++ src/plugin-sdk/provider-web-search.ts | 18 + src/plugin-sdk/qwen-portal-auth.ts | 3 + src/plugin-sdk/reply-runtime.ts | 31 ++ src/plugin-sdk/routing.ts | 27 +- src/plugin-sdk/runtime-env.ts | 21 ++ src/plugin-sdk/runtime-store.ts | 2 + src/plugin-sdk/runtime.ts | 18 + src/plugin-sdk/security-runtime.ts | 6 + src/plugin-sdk/setup.ts | 17 + src/plugin-sdk/signal.ts | 15 + src/plugin-sdk/slack.ts | 39 ++ src/plugin-sdk/speech.ts | 7 + src/plugin-sdk/state-paths.ts | 3 + src/plugin-sdk/telegram.ts | 64 ++++ src/plugin-sdk/test-utils.ts | 1 + src/plugin-sdk/text-runtime.ts | 23 ++ src/plugin-sdk/whatsapp.ts | 54 +++ src/plugin-sdk/zai.ts | 7 + src/security/audit-channel.runtime.ts | 2 +- 492 files changed, 5657 insertions(+), 2877 deletions(-) create mode 100644 src/plugin-sdk/acp-runtime.ts create mode 100644 src/plugin-sdk/agent-runtime.ts create mode 100644 src/plugin-sdk/channel-runtime.ts create mode 100644 src/plugin-sdk/cli-runtime.ts create mode 100644 src/plugin-sdk/config-runtime.ts create mode 100644 src/plugin-sdk/conversation-runtime.ts create mode 100644 src/plugin-sdk/gateway-runtime.ts create mode 100644 src/plugin-sdk/hook-runtime.ts create mode 100644 src/plugin-sdk/infra-runtime.ts create mode 100644 src/plugin-sdk/media-runtime.ts create mode 100644 src/plugin-sdk/plugin-runtime.ts create mode 100644 src/plugin-sdk/process-runtime.ts create mode 100644 src/plugin-sdk/provider-auth.ts create mode 100644 src/plugin-sdk/provider-models.ts create mode 100644 src/plugin-sdk/provider-onboard.ts create mode 100644 src/plugin-sdk/provider-stream.ts create mode 100644 src/plugin-sdk/provider-usage.ts create mode 100644 src/plugin-sdk/provider-web-search.ts create mode 100644 src/plugin-sdk/reply-runtime.ts create mode 100644 src/plugin-sdk/runtime-env.ts create mode 100644 src/plugin-sdk/security-runtime.ts create mode 100644 src/plugin-sdk/speech.ts create mode 100644 src/plugin-sdk/state-paths.ts create mode 100644 src/plugin-sdk/text-runtime.ts create mode 100644 src/plugin-sdk/zai.ts diff --git a/extensions/acpx/src/test-utils/runtime-fixtures.ts b/extensions/acpx/src/test-utils/runtime-fixtures.ts index c5cbef83877..ebf5052f450 100644 --- a/extensions/acpx/src/test-utils/runtime-fixtures.ts +++ b/extensions/acpx/src/test-utils/runtime-fixtures.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { resolvePreferredOpenClawTmpDir } from "../../../../src/infra/tmp-openclaw-dir.js"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime"; import type { ResolvedAcpxPluginConfig } from "../config.js"; import { ACPX_PINNED_VERSION } from "../config.js"; import { AcpxRuntime } from "../runtime.js"; diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index cf63e876354..25cb604dbcb 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -1,3 +1,5 @@ +import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; +import { parseDurationMs } from "openclaw/plugin-sdk/cli-runtime"; import { emptyPluginConfigSchema, type OpenClawPluginApi, @@ -7,26 +9,25 @@ import { } from "openclaw/plugin-sdk/core"; import { CLAUDE_CLI_PROFILE_ID, + applyAuthProfileConfig, + buildTokenProfileId, + createProviderApiKeyAuthMethod, + ensureApiKeyFromOptionEnvOrPrompt, listProfilesForProvider, - upsertAuthProfile, -} from "../../src/agents/auth-profiles.js"; -import { suggestOAuthProfileIdForLegacyDefault } from "../../src/agents/auth-profiles/repair.js"; -import type { AuthProfileStore } from "../../src/agents/auth-profiles/types.js"; -import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { formatCliCommand } from "../../src/cli/command-format.js"; -import { parseDurationMs } from "../../src/cli/parse-duration.js"; -import { + normalizeApiKeyInput, + suggestOAuthProfileIdForLegacyDefault, + type AuthProfileStore, + type ProviderAuthResult, + normalizeSecretInput, normalizeSecretInputModeInput, promptSecretRefForSetup, resolveSecretInputModeForEnvSelection, -} from "../../src/commands/auth-choice.apply-helpers.js"; -import { buildTokenProfileId, validateAnthropicSetupToken } from "../../src/commands/auth-token.js"; -import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; -import { fetchClaudeUsage } from "../../src/infra/provider-usage.fetch.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; -import type { ProviderAuthResult } from "../../src/plugins/types.js"; -import { normalizeSecretInput } from "../../src/utils/normalize-secret-input.js"; -import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js"; + upsertAuthProfile, + validateAnthropicSetupToken, + validateApiKeyInput, +} from "openclaw/plugin-sdk/provider-auth"; +import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; +import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage"; const PROVIDER_ID = "anthropic"; const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6"; @@ -395,7 +396,6 @@ const anthropicPlugin = { profileId: ctx.profileId, }), }); - api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider); }, }; diff --git a/extensions/brave/index.ts b/extensions/brave/index.ts index 1150dec5d80..f23c5d4d485 100644 --- a/extensions/brave/index.ts +++ b/extensions/brave/index.ts @@ -1,10 +1,9 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createPluginBackedWebSearchProvider, getTopLevelCredentialValue, setTopLevelCredentialValue, -} from "../../src/agents/tools/web-search-plugin-factory.js"; -import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; -import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +} from "openclaw/plugin-sdk/provider-web-search"; const bravePlugin = { id: "brave", diff --git a/extensions/byteplus/index.ts b/extensions/byteplus/index.ts index 7c6cf2f08fe..215ac1a1705 100644 --- a/extensions/byteplus/index.ts +++ b/extensions/byteplus/index.ts @@ -1,7 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { ensureModelAllowlistEntry } from "../../src/commands/model-allowlist.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; -import { buildPairedProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard"; import { buildBytePlusCodingProvider, buildBytePlusProvider } from "./provider-catalog.js"; const PROVIDER_ID = "byteplus"; @@ -46,15 +45,18 @@ const byteplusPlugin = { ], catalog: { order: "paired", - run: (ctx) => - buildPairedProviderApiKeyCatalog({ - ctx, - providerId: PROVIDER_ID, - buildProviders: () => ({ - byteplus: buildBytePlusProvider(), - "byteplus-plan": buildBytePlusCodingProvider(), - }), - }), + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + providers: { + byteplus: { ...buildBytePlusProvider(), apiKey }, + "byteplus-plan": { ...buildBytePlusCodingProvider(), apiKey }, + }, + }; + }, }, }); }, diff --git a/extensions/byteplus/provider-catalog.ts b/extensions/byteplus/provider-catalog.ts index 77cca06a2db..bcb5b153d20 100644 --- a/extensions/byteplus/provider-catalog.ts +++ b/extensions/byteplus/provider-catalog.ts @@ -4,8 +4,8 @@ import { 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"; + type ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-models"; export function buildBytePlusProvider(): ModelProviderConfig { return { diff --git a/extensions/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts index aa584af8208..6c3cda9d0d2 100644 --- a/extensions/cloudflare-ai-gateway/index.ts +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -1,21 +1,22 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { upsertAuthProfile } from "../../src/agents/auth-profiles.js"; -import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; +import { + applyAuthProfileConfig, + buildApiKeyCredential, + coerceSecretRef, + ensureApiKeyFromOptionEnvOrPrompt, + ensureAuthProfileStore, + listProfilesForProvider, + normalizeApiKeyInput, + normalizeOptionalSecretInput, + resolveNonEnvSecretRefApiKeyMarker, + type SecretInput, + upsertAuthProfile, + validateApiKeyInput, +} from "openclaw/plugin-sdk/provider-auth"; import { buildCloudflareAiGatewayModelDefinition, resolveCloudflareAiGatewayBaseUrl, -} from "../../src/agents/cloudflare-ai-gateway.js"; -import { resolveNonEnvSecretRefApiKeyMarker } from "../../src/agents/model-auth-markers.js"; -import { - normalizeApiKeyInput, - validateApiKeyInput, -} from "../../src/commands/auth-choice.api-key.js"; -import { ensureApiKeyFromOptionEnvOrPrompt } from "../../src/commands/auth-choice.apply-helpers.js"; -import { buildApiKeyCredential } from "../../src/commands/auth-credentials.js"; -import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; -import type { SecretInput } from "../../src/config/types.secrets.js"; -import { coerceSecretRef } from "../../src/config/types.secrets.js"; -import { normalizeOptionalSecretInput } from "../../src/utils/normalize-secret-input.js"; +} from "openclaw/plugin-sdk/provider-models"; import { applyCloudflareAiGatewayConfig, buildCloudflareAiGatewayConfigPatch, diff --git a/extensions/cloudflare-ai-gateway/onboard.ts b/extensions/cloudflare-ai-gateway/onboard.ts index 267c2f806f1..5260e1495a8 100644 --- a/extensions/cloudflare-ai-gateway/onboard.ts +++ b/extensions/cloudflare-ai-gateway/onboard.ts @@ -2,12 +2,12 @@ import { buildCloudflareAiGatewayModelDefinition, CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, resolveCloudflareAiGatewayBaseUrl, -} from "../../src/agents/cloudflare-ai-gateway.js"; +} from "openclaw/plugin-sdk/provider-models"; import { applyAgentDefaultModelPrimary, applyProviderConfigWithDefaultModel, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF }; diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index a998c5ba874..c74c630cee4 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -1,13 +1,13 @@ import { hasConfiguredSecretInput, normalizeSecretInputString, -} from "../../../src/config/types.secrets.js"; +} from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig, type DiscordAccountConfig, -} from "../../../src/plugin-sdk-internal/discord.js"; +} from "openclaw/plugin-sdk/discord"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId, diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index 39903077aaf..b9b8ede5fe1 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -3,12 +3,12 @@ import { createAccountListHelpers, normalizeAccountId, resolveAccountEntry, -} from "../../../src/plugin-sdk-internal/accounts.js"; +} from "openclaw/plugin-sdk/account-resolution"; import type { - OpenClawConfig, DiscordAccountConfig, DiscordActionConfig, -} from "../../../src/plugin-sdk-internal/discord.js"; + OpenClawConfig, +} from "openclaw/plugin-sdk/discord"; import { resolveDiscordToken } from "./token.js"; export type ResolvedDiscordAccount = { diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts index 80cd97217ae..0f6075384a5 100644 --- a/extensions/discord/src/actions/handle-action.guild-admin.ts +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -4,13 +4,13 @@ import { readNumberParam, readStringArrayParam, readStringParam, -} from "../../../../src/agents/tools/common.js"; +} from "openclaw/plugin-sdk/agent-runtime"; import { isDiscordModerationAction, readDiscordModerationCommand, -} from "../../../../src/agents/tools/discord-actions-moderation-shared.js"; -import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js"; -import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; +} from "openclaw/plugin-sdk/agent-runtime"; +import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; type Ctx = Pick< ChannelMessageActionContext, diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index c938d675955..d23b078292a 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -1,15 +1,15 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { readNumberParam, readStringArrayParam, readStringParam, -} from "../../../../src/agents/tools/common.js"; -import { readDiscordParentIdParam } from "../../../../src/agents/tools/discord-actions-shared.js"; -import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js"; -import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js"; -import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; -import { normalizeInteractiveReply } from "../../../../src/interactive/payload.js"; +} from "openclaw/plugin-sdk/agent-runtime"; +import { readDiscordParentIdParam } from "openclaw/plugin-sdk/agent-runtime"; +import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime"; +import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; +import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; +import { normalizeInteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; import { buildDiscordInteractiveComponents } from "../shared-interactive.js"; import { resolveDiscordChannelId } from "../targets.js"; import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; diff --git a/extensions/discord/src/api.ts b/extensions/discord/src/api.ts index cead5eb8cea..0352656d21b 100644 --- a/extensions/discord/src/api.ts +++ b/extensions/discord/src/api.ts @@ -1,5 +1,9 @@ -import { resolveFetch } from "../../../src/infra/fetch.js"; -import { resolveRetryConfig, retryAsync, type RetryConfig } from "../../../src/infra/retry.js"; +import { resolveFetch } from "openclaw/plugin-sdk/infra-runtime"; +import { + resolveRetryConfig, + retryAsync, + type RetryConfig, +} from "openclaw/plugin-sdk/infra-runtime"; const DISCORD_API_BASE = "https://discord.com/api/v10"; const DISCORD_API_RETRY_DEFAULTS = { diff --git a/extensions/discord/src/audit.ts b/extensions/discord/src/audit.ts index a5a226c5550..79bc9b5b5fc 100644 --- a/extensions/discord/src/audit.ts +++ b/extensions/discord/src/audit.ts @@ -1,6 +1,9 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../../../src/config/types.js"; -import { isRecord } from "../../../src/utils.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { + DiscordGuildChannelConfig, + DiscordGuildEntry, +} from "openclaw/plugin-sdk/config-runtime"; +import { isRecord } from "openclaw/plugin-sdk/text-runtime"; import { inspectDiscordAccount } from "./account-inspect.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index 049eb4a320c..21f24fd9553 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -1,12 +1,12 @@ import { createUnionActionGate, listTokenSourcedAccounts, -} from "../../../src/channels/plugins/actions/shared.js"; +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageActionName, -} from "../../../src/channels/plugins/types.js"; -import type { DiscordActionConfig } from "../../../src/config/types.discord.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-runtime"; import { createDiscordActionGate, listEnabledDiscordAccounts } from "./accounts.js"; import { handleDiscordMessageAction } from "./actions/handle-action.js"; diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts index 5c7bfe6e659..1988c03ca26 100644 --- a/extensions/discord/src/channel.setup.ts +++ b/extensions/discord/src/channel.setup.ts @@ -1,8 +1,43 @@ -import type { ChannelPlugin } from "openclaw/plugin-sdk/discord"; -import type { ResolvedDiscordAccount } from "./accounts.js"; +import { + buildChannelConfigSchema, + DiscordConfigSchema, + getChatChannelMeta, + type ChannelPlugin, +} from "openclaw/plugin-sdk/discord"; +import { type ResolvedDiscordAccount } from "./accounts.js"; +import { discordConfigAccessors, discordConfigBase, discordSetupWizard } from "./plugin-shared.js"; import { discordSetupAdapter } from "./setup-core.js"; -import { createDiscordPluginBase } from "./shared.js"; -export const discordSetupPlugin: ChannelPlugin = createDiscordPluginBase({ +export const discordSetupPlugin: ChannelPlugin = { + id: "discord", + meta: { + ...getChatChannelMeta("discord"), + }, + setupWizard: discordSetupWizard, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + polls: true, + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.discord"] }, + configSchema: buildChannelConfigSchema(DiscordConfigSchema), + config: { + ...discordConfigBase, + isConfigured: (account) => Boolean(account.token?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.token?.trim()), + tokenSource: account.tokenSource, + }), + ...discordConfigAccessors, + }, setup: discordSetupAdapter, -}); +}; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index b598f004cf7..d12813e66a6 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,10 +1,12 @@ import { Separator, TextDisplay } from "@buape/carbon"; +import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, - collectOpenProviderGroupPolicyWarnings, collectOpenGroupPolicyConfiguredRouteWarnings, -} from "openclaw/plugin-sdk/compat"; + collectOpenProviderGroupPolicyWarnings, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; import { buildAgentSessionKey, resolveThreadSessionKeys, @@ -12,8 +14,11 @@ import { } from "openclaw/plugin-sdk/core"; import { buildComputedAccountStatusSnapshot, + buildChannelConfigSchema, buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, + DiscordConfigSchema, + getChatChannelMeta, listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, PAIRING_APPROVED_MESSAGE, @@ -25,9 +30,6 @@ import { type ChannelPlugin, type OpenClawConfig, } from "openclaw/plugin-sdk/discord"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; -import { normalizeOutboundThreadId } from "../../../src/infra/outbound/thread-id.js"; -import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; import { listDiscordAccountIds, resolveDiscordAccount, @@ -43,12 +45,12 @@ import { normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, } from "./normalize.js"; +import { discordConfigAccessors, discordConfigBase, discordSetupWizard } from "./plugin-shared.js"; import type { DiscordProbe } from "./probe.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { getDiscordRuntime } from "./runtime.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; import { discordSetupAdapter } from "./setup-core.js"; -import { createDiscordPluginBase, discordConfigAccessors } from "./shared.js"; import { collectDiscordStatusIssues } from "./status-issues.js"; import { parseDiscordTarget } from "./targets.js"; import { DiscordUiContainer } from "./ui.js"; @@ -57,6 +59,7 @@ type DiscordSendFn = ReturnType< typeof getDiscordRuntime >["channel"]["discord"]["sendMessageDiscord"]; +const meta = getChatChannelMeta("discord"); const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; function formatDiscordIntents(intents?: { @@ -197,6 +200,20 @@ function parseDiscordExplicitTarget(raw: string) { } } +function normalizeOutboundThreadId(value?: string | number | null): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return undefined; + } + return String(Math.trunc(value)); + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + function buildDiscordBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; @@ -280,9 +297,11 @@ function resolveDiscordOutboundSessionRoute(params: { } export const discordPlugin: ChannelPlugin = { - ...createDiscordPluginBase({ - setup: discordSetupAdapter, - }), + id: "discord", + meta: { + ...meta, + }, + setupWizard: discordSetupWizard, pairing: { idLabel: "discordUserId", normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), @@ -293,6 +312,31 @@ export const discordPlugin: ChannelPlugin = { ); }, }, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + polls: true, + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.discord"] }, + configSchema: buildChannelConfigSchema(DiscordConfigSchema), + config: { + ...discordConfigBase, + isConfigured: (account) => Boolean(account.token?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.token?.trim()), + tokenSource: account.tokenSource, + }), + ...discordConfigAccessors, + }, allowlist: { supportsScope: ({ scope }) => scope === "dm", readConfig: ({ cfg, accountId }) => diff --git a/extensions/discord/src/chunk.ts b/extensions/discord/src/chunk.ts index a814c10d2c8..5efff023152 100644 --- a/extensions/discord/src/chunk.ts +++ b/extensions/discord/src/chunk.ts @@ -1,4 +1,4 @@ -import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../src/auto-reply/chunk.js"; +import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; export type ChunkDiscordTextOpts = { /** Max characters per Discord message. Default: 2000. */ diff --git a/extensions/discord/src/client.ts b/extensions/discord/src/client.ts index 2e8d53799a6..2688add72cd 100644 --- a/extensions/discord/src/client.ts +++ b/extensions/discord/src/client.ts @@ -1,8 +1,8 @@ import { RequestClient } from "@buape/carbon"; -import { loadConfig } from "../../../src/config/config.js"; -import { createDiscordRetryRunner, type RetryRunner } from "../../../src/infra/retry-policy.js"; -import type { RetryConfig } from "../../../src/infra/retry.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { createDiscordRetryRunner, type RetryRunner } from "openclaw/plugin-sdk/infra-runtime"; +import type { RetryConfig } from "openclaw/plugin-sdk/infra-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { mergeDiscordAccountConfig, resolveDiscordAccount, diff --git a/extensions/discord/src/directory-cache.ts b/extensions/discord/src/directory-cache.ts index d1a85767216..cc8c9d7c546 100644 --- a/extensions/discord/src/directory-cache.ts +++ b/extensions/discord/src/directory-cache.ts @@ -1,4 +1,4 @@ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/account-id.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; const DISCORD_DIRECTORY_CACHE_MAX_ENTRIES = 4000; const DISCORD_DISCRIMINATOR_SUFFIX = /#\d{4}$/; diff --git a/extensions/discord/src/directory-live.ts b/extensions/discord/src/directory-live.ts index af55475a43e..6bd38204a0a 100644 --- a/extensions/discord/src/directory-live.ts +++ b/extensions/discord/src/directory-live.ts @@ -1,5 +1,5 @@ -import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; -import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js"; +import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/channel-runtime"; import { resolveDiscordAccount } from "./accounts.js"; import { fetchDiscord } from "./api.js"; import { rememberDiscordDirectoryUser } from "./directory-cache.js"; diff --git a/extensions/discord/src/draft-chunking.ts b/extensions/discord/src/draft-chunking.ts index a6461412ae7..98cc48a2f9f 100644 --- a/extensions/discord/src/draft-chunking.ts +++ b/extensions/discord/src/draft-chunking.ts @@ -1,7 +1,7 @@ -import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.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 { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { DISCORD_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; const DEFAULT_DISCORD_DRAFT_STREAM_MIN = 200; diff --git a/extensions/discord/src/draft-stream.ts b/extensions/discord/src/draft-stream.ts index db9089f6176..a12348334bc 100644 --- a/extensions/discord/src/draft-stream.ts +++ b/extensions/discord/src/draft-stream.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; -import { createFinalizableDraftLifecycle } from "../../../src/channels/draft-stream-controls.js"; +import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-runtime"; /** Discord messages cap at 2000 characters. */ const DISCORD_STREAM_MAX_CHARS = 2000; diff --git a/extensions/discord/src/exec-approvals.ts b/extensions/discord/src/exec-approvals.ts index 5640805705a..bdafce36713 100644 --- a/extensions/discord/src/exec-approvals.ts +++ b/extensions/discord/src/exec-approvals.ts @@ -1,6 +1,6 @@ -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { getExecApprovalReplyMetadata } from "../../../src/infra/exec-approval-reply.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/infra-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { resolveDiscordAccount } from "./accounts.js"; export function isDiscordExecApprovalClientEnabled(params: { diff --git a/extensions/discord/src/gateway-logging.ts b/extensions/discord/src/gateway-logging.ts index 18ce32909ef..3a6802ccaef 100644 --- a/extensions/discord/src/gateway-logging.ts +++ b/extensions/discord/src/gateway-logging.ts @@ -1,6 +1,6 @@ import type { EventEmitter } from "node:events"; -import { logVerbose } from "../../../src/globals.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; type GatewayEmitter = Pick; diff --git a/extensions/discord/src/monitor.tool-result.test-harness.ts b/extensions/discord/src/monitor.tool-result.test-harness.ts index 700e9a63df3..fd4f67b0890 100644 --- a/extensions/discord/src/monitor.tool-result.test-harness.ts +++ b/extensions/discord/src/monitor.tool-result.test-harness.ts @@ -1,5 +1,5 @@ +import type { MockFn } from "openclaw/plugin-sdk/test-utils"; import { vi } from "vitest"; -import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; export const sendMock: MockFn = vi.fn(); export const reactMock: MockFn = vi.fn(); @@ -15,8 +15,8 @@ vi.mock("./send.js", () => ({ }, })); -vi.mock("../../../src/auto-reply/dispatch.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args), @@ -36,10 +36,10 @@ function createPairingStoreMocks() { }; } -vi.mock("../../../src/pairing/pairing-store.js", () => createPairingStoreMocks()); +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => createPairingStoreMocks()); -vi.mock("../../../src/config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index e28bd17b70e..5ac63e76d51 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -18,41 +18,41 @@ import { } from "@buape/carbon"; import type { APIStringSelectComponent } from "discord-api-types/v10"; import { ButtonStyle, ChannelType } from "discord-api-types/v10"; -import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; -import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; -import { - formatInboundEnvelope, - resolveEnvelopeFormatOptions, -} from "../../../../src/auto-reply/envelope.js"; -import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../../../../src/auto-reply/reply/provider-dispatcher.js"; -import { createReplyReferencePlanner } from "../../../../src/auto-reply/reply/reply-reference.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; -import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; -import { recordInboundSession } from "../../../../src/channels/session.js"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; -import { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js"; -import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js"; -import { logVerbose } from "../../../../src/globals.js"; -import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; -import { logDebug, logError } from "../../../../src/logger.js"; -import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js"; -import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; +import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; +import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; +import { issuePairingChallenge } from "openclaw/plugin-sdk/conversation-runtime"; +import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { buildPluginBindingResolvedText, parsePluginBindingApprovalCustomId, resolvePluginConversationBindingApproval, -} from "../../../../src/plugins/conversation-binding.js"; -import { dispatchPluginInteractiveHandler } from "../../../../src/plugins/interactive.js"; -import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; -import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; +} from "openclaw/plugin-sdk/conversation-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime"; +import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "openclaw/plugin-sdk/reply-runtime"; +import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime"; +import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { readStoreAllowFromForDmPolicy, resolvePinnedMainDmOwnerFromAllowlist, -} from "../../../../src/security/dm-policy-shared.js"; +} from "openclaw/plugin-sdk/security-runtime"; +import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; import { diff --git a/extensions/discord/src/monitor/allow-list.ts b/extensions/discord/src/monitor/allow-list.ts index a6208eaf63a..31d95f2f45b 100644 --- a/extensions/discord/src/monitor/allow-list.ts +++ b/extensions/discord/src/monitor/allow-list.ts @@ -1,12 +1,12 @@ import type { Guild, User } from "@buape/carbon"; -import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access"; -import type { AllowlistMatch } from "../../../../src/channels/allowlist-match.js"; +import type { AllowlistMatch } from "openclaw/plugin-sdk/channel-runtime"; import { buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, resolveChannelMatchConfig, type ChannelMatchSource, -} from "../../../../src/channels/channel-config.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { formatDiscordUserTag } from "./format.js"; export type DiscordAllowList = { diff --git a/extensions/discord/src/monitor/auto-presence.ts b/extensions/discord/src/monitor/auto-presence.ts index 60e5619e348..b76ea6f6d5c 100644 --- a/extensions/discord/src/monitor/auto-presence.ts +++ b/extensions/discord/src/monitor/auto-presence.ts @@ -6,12 +6,12 @@ import { resolveProfilesUnavailableReason, type AuthProfileFailureReason, type AuthProfileStore, -} from "../../../../src/agents/auth-profiles.js"; +} from "openclaw/plugin-sdk/agent-runtime"; import type { DiscordAccountConfig, DiscordAutoPresenceConfig, -} from "../../../../src/config/config.js"; -import { warn } from "../../../../src/globals.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { warn } from "openclaw/plugin-sdk/runtime-env"; import { resolveDiscordPresenceUpdate } from "./presence.js"; const DEFAULT_CUSTOM_ACTIVITY_TYPE = 4; diff --git a/extensions/discord/src/monitor/commands.ts b/extensions/discord/src/monitor/commands.ts index a9bb9c1548e..43e92ea9122 100644 --- a/extensions/discord/src/monitor/commands.ts +++ b/extensions/discord/src/monitor/commands.ts @@ -1,4 +1,4 @@ -import type { DiscordSlashCommandConfig } from "../../../../src/config/types.discord.js"; +import type { DiscordSlashCommandConfig } from "openclaw/plugin-sdk/config-runtime"; export function resolveDiscordSlashCommandConfig( raw?: DiscordSlashCommandConfig, diff --git a/extensions/discord/src/monitor/dm-command-auth.ts b/extensions/discord/src/monitor/dm-command-auth.ts index 2fa02d9d605..1e8f1afbb4b 100644 --- a/extensions/discord/src/monitor/dm-command-auth.ts +++ b/extensions/discord/src/monitor/dm-command-auth.ts @@ -1,9 +1,9 @@ -import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, type DmGroupAccessDecision, -} from "../../../../src/security/dm-policy-shared.js"; +} from "openclaw/plugin-sdk/security-runtime"; import { normalizeDiscordAllowList, resolveDiscordAllowListMatch } from "./allow-list.js"; const DISCORD_ALLOW_LIST_PREFIXES = ["discord:", "user:", "pk:"]; diff --git a/extensions/discord/src/monitor/dm-command-decision.ts b/extensions/discord/src/monitor/dm-command-decision.ts index 8c15e7cac11..ec5cb6330e0 100644 --- a/extensions/discord/src/monitor/dm-command-decision.ts +++ b/extensions/discord/src/monitor/dm-command-decision.ts @@ -1,5 +1,5 @@ -import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; +import { issuePairingChallenge } from "openclaw/plugin-sdk/conversation-runtime"; +import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import type { DiscordDmCommandAccess } from "./dm-command-auth.js"; export async function handleDiscordDmCommandDecision(params: { diff --git a/extensions/discord/src/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts index e5fda7682a9..607d5088ad1 100644 --- a/extensions/discord/src/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -10,30 +10,24 @@ import { type TopLevelComponents, } from "@buape/carbon"; import { ButtonStyle, Routes } from "discord-api-types/v10"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import { loadSessionStore, resolveStorePath } from "../../../../src/config/sessions.js"; -import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js"; -import { GatewayClient } from "../../../../src/gateway/client.js"; -import { createOperatorApprovalsGatewayClient } from "../../../../src/gateway/operator-approvals-client.js"; -import type { EventFrame } from "../../../../src/gateway/protocol/index.js"; -import { resolveExecApprovalCommandDisplay } from "../../../../src/infra/exec-approval-command-display.js"; -import { getExecApprovalApproverDmNoticeText } from "../../../../src/infra/exec-approval-reply.js"; +import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime"; +import { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime"; +import { createOperatorApprovalsGatewayClient } from "openclaw/plugin-sdk/gateway-runtime"; +import type { EventFrame } from "openclaw/plugin-sdk/gateway-runtime"; +import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; +import { getExecApprovalApproverDmNoticeText } from "openclaw/plugin-sdk/infra-runtime"; import type { ExecApprovalDecision, ExecApprovalRequest, ExecApprovalResolved, -} from "../../../../src/infra/exec-approvals.js"; -import { logDebug, logError } from "../../../../src/logger.js"; -import { - normalizeAccountId, - resolveAgentIdFromSessionKey, -} from "../../../../src/routing/session-key.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; -import { - compileSafeRegex, - testRegexWithBoundedInput, -} from "../../../../src/security/safe-regex.js"; -import { normalizeMessageChannel } from "../../../../src/utils/message-channel.js"; +} from "openclaw/plugin-sdk/infra-runtime"; +import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { compileSafeRegex, testRegexWithBoundedInput } from "openclaw/plugin-sdk/security-runtime"; +import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime"; import { createDiscordClient, stripUndefinedFields } from "../send.shared.js"; import { DiscordUiContainer } from "../ui.js"; diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 1799c16d79e..109135a3684 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -1,11 +1,11 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; import type { APIGatewayBotInfo } from "discord-api-types/v10"; import { HttpsProxyAgent } from "https-proxy-agent"; +import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { ProxyAgent, fetch as undiciFetch } from "undici"; import WebSocket from "ws"; -import type { DiscordAccountConfig } from "../../../../src/config/types.js"; -import { danger } from "../../../../src/globals.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot"; const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/"; @@ -20,7 +20,7 @@ type DiscordGatewayFetch = ( ) => Promise; export function resolveDiscordGatewayIntents( - intentsConfig?: import("../../../../src/config/types.discord.js").DiscordIntentsConfig, + intentsConfig?: import("openclaw/plugin-sdk/config-runtime").DiscordIntentsConfig, ): number { let intents = GatewayIntents.Guilds | diff --git a/extensions/discord/src/monitor/inbound-context.ts b/extensions/discord/src/monitor/inbound-context.ts index 26b2a07f03e..1f0608d3529 100644 --- a/extensions/discord/src/monitor/inbound-context.ts +++ b/extensions/discord/src/monitor/inbound-context.ts @@ -1,4 +1,4 @@ -import { buildUntrustedChannelMetadata } from "../../../../src/security/channel-metadata.js"; +import { buildUntrustedChannelMetadata } from "openclaw/plugin-sdk/security-runtime"; import { resolveDiscordOwnerAllowFrom, type DiscordChannelConfigResolved, diff --git a/extensions/discord/src/monitor/inbound-worker.ts b/extensions/discord/src/monitor/inbound-worker.ts index cbc8e246704..33986e458a3 100644 --- a/extensions/discord/src/monitor/inbound-worker.ts +++ b/extensions/discord/src/monitor/inbound-worker.ts @@ -1,7 +1,7 @@ +import { createRunStateMachine } from "openclaw/plugin-sdk/channel-runtime"; +import { formatDurationSeconds } from "openclaw/plugin-sdk/infra-runtime"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; -import { createRunStateMachine } from "../../../../src/channels/run-state-machine.js"; -import { danger } from "../../../../src/globals.js"; -import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; import { materializeDiscordInboundJob, type DiscordInboundJob } from "./inbound-job.js"; import type { RuntimeEnv } from "./message-handler.preflight.types.js"; import { processDiscordMessage } from "./message-handler.process.js"; diff --git a/extensions/discord/src/monitor/listeners.ts b/extensions/discord/src/monitor/listeners.ts index 318435d5318..9ed94d0a52f 100644 --- a/extensions/discord/src/monitor/listeners.ts +++ b/extensions/discord/src/monitor/listeners.ts @@ -8,16 +8,16 @@ import { ThreadUpdateListener, type User, } from "@buape/carbon"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import { danger, logVerbose } from "../../../../src/globals.js"; -import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts"; -import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; -import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; -import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { formatDurationSeconds } from "openclaw/plugin-sdk/infra-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, -} from "../../../../src/security/dm-policy-shared.js"; +} from "openclaw/plugin-sdk/security-runtime"; import { isDiscordGroupAllowedByPolicy, normalizeDiscordAllowList, @@ -36,11 +36,9 @@ import { isThreadArchived } from "./thread-bindings.discord-api.js"; import { closeDiscordThreadSessions } from "./thread-session-close.js"; import { normalizeDiscordListenerTimeoutMs, runDiscordTaskWithTimeout } from "./timeouts.js"; -type LoadedConfig = ReturnType; -type RuntimeEnv = import("../../../../src/runtime.js").RuntimeEnv; -type Logger = ReturnType< - typeof import("../../../../src/logging/subsystem.js").createSubsystemLogger ->; +type LoadedConfig = ReturnType; +type RuntimeEnv = import("openclaw/plugin-sdk/runtime-env").RuntimeEnv; +type Logger = ReturnType; export type DiscordMessageEvent = Parameters[0]; diff --git a/extensions/discord/src/monitor/message-handler.module-test-helpers.ts b/extensions/discord/src/monitor/message-handler.module-test-helpers.ts index 83174ad5621..adeaf7953e7 100644 --- a/extensions/discord/src/monitor/message-handler.module-test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.module-test-helpers.ts @@ -1,5 +1,5 @@ +import type { MockFn } from "openclaw/plugin-sdk/test-utils"; import { vi } from "vitest"; -import type { MockFn } from "../../../../src/test-utils/vitest-mock-fn.js"; export const preflightDiscordMessageMock: MockFn = vi.fn(); export const processDiscordMessageMock: MockFn = vi.fn(); diff --git a/extensions/discord/src/monitor/message-handler.preflight.test-helpers.ts b/extensions/discord/src/monitor/message-handler.preflight.test-helpers.ts index 24895d287f7..8c6aa5f3cc1 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test-helpers.ts @@ -1,5 +1,5 @@ import { ChannelType } from "@buape/carbon"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { preflightDiscordMessage } from "./message-handler.preflight.js"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; @@ -90,7 +90,7 @@ export function createDiscordPreflightArgs(params: { discordConfig: params.discordConfig, accountId: "default", token: "token", - runtime: {} as import("../../../../src/runtime.js").RuntimeEnv, + runtime: {} as import("openclaw/plugin-sdk/runtime-env").RuntimeEnv, botUserId: params.botUserId ?? "openclaw-bot", guildHistories: new Map(), historyLimit: 0, diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 77640784063..0a402518927 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -1,36 +1,33 @@ import { ChannelType, MessageType, type User } from "@buape/carbon"; +import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; +import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { ensureConfiguredAcpRouteReady, resolveConfiguredAcpRoute, -} from "../../../../src/acp/persistent-bindings.route.js"; -import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js"; -import { shouldHandleTextCommands } from "../../../../src/auto-reply/commands-registry.js"; -import { - recordPendingHistoryEntryIfEnabled, - type HistoryEntry, -} from "../../../../src/auto-reply/reply/history.js"; -import { - buildMentionRegexes, - matchesMentionWithExplicit, -} from "../../../../src/auto-reply/reply/mentions.js"; -import { formatAllowlistMatchMeta } from "../../../../src/channels/allowlist-match.js"; -import { resolveControlCommandGate } from "../../../../src/channels/command-gating.js"; -import { logInboundDrop } from "../../../../src/channels/logging.js"; -import { resolveMentionGatingWithBypass } from "../../../../src/channels/mention-gating.js"; -import { loadConfig } from "../../../../src/config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; -import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; -import { recordChannelActivity } from "../../../../src/infra/channel-activity.js"; +} from "openclaw/plugin-sdk/conversation-runtime"; import { getSessionBindingService, type SessionBindingRecord, -} from "../../../../src/infra/outbound/session-binding-service.js"; -import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; -import { logDebug } from "../../../../src/logger.js"; -import { getChildLogger } from "../../../../src/logging.js"; -import { buildPairingReply } from "../../../../src/pairing/pairing-messages.js"; -import { isPluginOwnedSessionBindingRecord } from "../../../../src/plugins/conversation-binding.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../../src/routing/session-key.js"; +} from "openclaw/plugin-sdk/conversation-runtime"; +import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; +import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; +import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; +import { shouldHandleTextCommands } from "openclaw/plugin-sdk/reply-runtime"; +import { + recordPendingHistoryEntryIfEnabled, + type HistoryEntry, +} from "openclaw/plugin-sdk/reply-runtime"; +import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; +import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; +import { logDebug } from "openclaw/plugin-sdk/text-runtime"; import { fetchPluralKitMessageInfo } from "../pluralkit.js"; import { sendMessageDiscord } from "../send.js"; import { diff --git a/extensions/discord/src/monitor/message-handler.preflight.types.ts b/extensions/discord/src/monitor/message-handler.preflight.types.ts index a123a22dcaa..368352e1551 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.types.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.types.ts @@ -1,8 +1,8 @@ import type { ChannelType, Client, User } from "@buape/carbon"; -import type { HistoryEntry } from "../../../../src/auto-reply/reply/history.js"; -import type { ReplyToMode } from "../../../../src/config/config.js"; -import type { SessionBindingRecord } from "../../../../src/infra/outbound/session-binding-service.js"; -import type { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; +import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js"; import type { DiscordChannelInfo } from "./message-utils.js"; import type { DiscordThreadBindingLookup } from "./reply-delivery.js"; @@ -11,15 +11,17 @@ import type { DiscordSenderIdentity } from "./sender-identity.js"; export type { DiscordSenderIdentity } from "./sender-identity.js"; import type { DiscordThreadChannel } from "./threading.js"; -export type LoadedConfig = ReturnType; -export type RuntimeEnv = import("../../../../src/runtime.js").RuntimeEnv; +export type LoadedConfig = ReturnType< + typeof import("openclaw/plugin-sdk/config-runtime").loadConfig +>; +export type RuntimeEnv = import("openclaw/plugin-sdk/runtime-env").RuntimeEnv; export type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; type DiscordMessagePreflightSharedFields = { cfg: LoadedConfig; discordConfig: NonNullable< - import("../../../../src/config/config.js").OpenClawConfig["channels"] + import("openclaw/plugin-sdk/config-runtime").OpenClawConfig["channels"] >["discord"]; accountId: string; token: string; diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index dc86c3720ef..526ca4ecb71 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -1,40 +1,40 @@ import { ChannelType, type RequestClient } from "@buape/carbon"; -import { resolveAckReaction, resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; -import { EmbeddedBlockChunker } from "../../../../src/agents/pi-embedded-block-chunker.js"; -import { resolveChunkMode } from "../../../../src/auto-reply/chunk.js"; -import { dispatchInboundMessage } from "../../../../src/auto-reply/dispatch.js"; -import { - formatInboundEnvelope, - resolveEnvelopeFormatOptions, -} from "../../../../src/auto-reply/envelope.js"; -import { - buildPendingHistoryContextFromMap, - clearHistoryEntriesIfEnabled, -} from "../../../../src/auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; -import { createReplyDispatcherWithTyping } from "../../../../src/auto-reply/reply/reply-dispatcher.js"; -import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; -import { shouldAckReaction as shouldAckReactionGate } from "../../../../src/channels/ack-reactions.js"; -import { logTypingFailure, logAckFailure } from "../../../../src/channels/logging.js"; -import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; -import { recordInboundSession } from "../../../../src/channels/session.js"; +import { resolveAckReaction, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime"; +import { shouldAckReaction as shouldAckReactionGate } from "openclaw/plugin-sdk/channel-runtime"; +import { logTypingFailure, logAckFailure } from "openclaw/plugin-sdk/channel-runtime"; +import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; import { createStatusReactionController, DEFAULT_TIMING, type StatusReactionAdapter, -} from "../../../../src/channels/status-reactions.js"; -import { createTypingCallbacks } from "../../../../src/channels/typing.js"; -import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; -import { resolveDiscordPreviewStreamMode } from "../../../../src/config/discord-preview-streaming.js"; -import { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; -import { convertMarkdownTables } from "../../../../src/markdown/tables.js"; -import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js"; -import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js"; -import { resolveThreadSessionKeys } from "../../../../src/routing/session-key.js"; -import { stripReasoningTagsFromText } from "../../../../src/shared/text/reasoning-tags.js"; -import { truncateUtf16Safe } from "../../../../src/utils.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; +import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "openclaw/plugin-sdk/reply-runtime"; +import { + buildPendingHistoryContextFromMap, + clearHistoryEntriesIfEnabled, +} from "openclaw/plugin-sdk/reply-runtime"; +import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; +import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing"; +import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; +import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js"; diff --git a/extensions/discord/src/monitor/message-handler.test-helpers.ts b/extensions/discord/src/monitor/message-handler.test-helpers.ts index 04bfb9b603c..ed232ae43fb 100644 --- a/extensions/discord/src/monitor/message-handler.test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.test-helpers.ts @@ -1,5 +1,5 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { vi } from "vitest"; -import type { OpenClawConfig } from "../../../../src/config/types.js"; import type { createDiscordMessageHandler } from "./message-handler.js"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; diff --git a/extensions/discord/src/monitor/message-handler.ts b/extensions/discord/src/monitor/message-handler.ts index 2c9745a8bf0..400f35a2529 100644 --- a/extensions/discord/src/monitor/message-handler.ts +++ b/extensions/discord/src/monitor/message-handler.ts @@ -2,9 +2,9 @@ import type { Client } from "@buape/carbon"; import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "../../../../src/channels/inbound-debounce-policy.js"; -import { resolveOpenProviderRuntimeGroupPolicy } from "../../../../src/config/runtime-group-policy.js"; -import { danger } from "../../../../src/globals.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; import { buildDiscordInboundJob } from "./inbound-job.js"; import { createDiscordInboundWorker } from "./inbound-worker.js"; import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js"; diff --git a/extensions/discord/src/monitor/message-utils.ts b/extensions/discord/src/monitor/message-utils.ts index ae37d6615fd..4e84f4b3827 100644 --- a/extensions/discord/src/monitor/message-utils.ts +++ b/extensions/discord/src/monitor/message-utils.ts @@ -1,10 +1,10 @@ import type { ChannelType, Client, Message } from "@buape/carbon"; import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10"; -import { buildMediaPayload } from "../../../../src/channels/plugins/media-payload.js"; -import { logVerbose } from "../../../../src/globals.js"; -import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js"; -import { fetchRemoteMedia, type FetchLike } from "../../../../src/media/fetch.js"; -import { saveMediaBuffer } from "../../../../src/media/store.js"; +import { buildMediaPayload } from "openclaw/plugin-sdk/channel-runtime"; +import type { SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime"; +import { fetchRemoteMedia, type FetchLike } from "openclaw/plugin-sdk/media-runtime"; +import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; const DISCORD_CDN_HOSTNAMES = [ "cdn.discordapp.com", diff --git a/extensions/discord/src/monitor/model-picker-preferences.ts b/extensions/discord/src/monitor/model-picker-preferences.ts index 8657ed66436..ca3483678af 100644 --- a/extensions/discord/src/monitor/model-picker-preferences.ts +++ b/extensions/discord/src/monitor/model-picker-preferences.ts @@ -1,11 +1,11 @@ import os from "node:os"; import path from "node:path"; import { normalizeAccountId as normalizeSharedAccountId } from "openclaw/plugin-sdk/account-id"; +import { normalizeProviderId } from "openclaw/plugin-sdk/agent-runtime"; +import { withFileLock } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveRequiredHomeDir } from "openclaw/plugin-sdk/infra-runtime"; import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store"; -import { normalizeProviderId } from "../../../../src/agents/model-selection.js"; -import { resolveStateDir } from "../../../../src/config/paths.js"; -import { withFileLock } from "../../../../src/infra/file-lock.js"; -import { resolveRequiredHomeDir } from "../../../../src/infra/home-dir.js"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = { retries: { diff --git a/extensions/discord/src/monitor/model-picker.test-utils.ts b/extensions/discord/src/monitor/model-picker.test-utils.ts index 8d9a9dd3197..56dcd7480c1 100644 --- a/extensions/discord/src/monitor/model-picker.test-utils.ts +++ b/extensions/discord/src/monitor/model-picker.test-utils.ts @@ -1,4 +1,4 @@ -import type { ModelsProviderData } from "../../../../src/auto-reply/reply/commands-models.js"; +import type { ModelsProviderData } from "openclaw/plugin-sdk/reply-runtime"; export function createModelsProviderData( entries: Record, diff --git a/extensions/discord/src/monitor/model-picker.ts b/extensions/discord/src/monitor/model-picker.ts index fb9226ac899..ec067ede2dd 100644 --- a/extensions/discord/src/monitor/model-picker.ts +++ b/extensions/discord/src/monitor/model-picker.ts @@ -11,12 +11,12 @@ import { } from "@buape/carbon"; import type { APISelectMenuOption } from "discord-api-types/v10"; import { ButtonStyle } from "discord-api-types/v10"; -import { normalizeProviderId } from "../../../../src/agents/model-selection.js"; +import { normalizeProviderId } from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { buildModelsProviderData, type ModelsProviderData, -} from "../../../../src/auto-reply/reply/commands-models.js"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; +} from "openclaw/plugin-sdk/reply-runtime"; export const DISCORD_MODEL_PICKER_CUSTOM_ID_KEY = "mdlpk"; export const DISCORD_CUSTOM_ID_MAX_CHARS = 100; diff --git a/extensions/discord/src/monitor/native-command-context.ts b/extensions/discord/src/monitor/native-command-context.ts index fc650827d45..07dc0bf0a76 100644 --- a/extensions/discord/src/monitor/native-command-context.ts +++ b/extensions/discord/src/monitor/native-command-context.ts @@ -1,5 +1,5 @@ -import type { CommandArgs } from "../../../../src/auto-reply/commands-registry.js"; -import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; +import type { CommandArgs } from "openclaw/plugin-sdk/reply-runtime"; +import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { type DiscordChannelConfigResolved, type DiscordGuildEntryResolved } from "./allow-list.js"; import { buildDiscordInboundAccessContext } from "./inbound-context.js"; diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index e745063d8d0..ed50aff52a3 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -15,19 +15,29 @@ import { type StringSelectMenuInteraction, } from "@buape/carbon"; import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10"; +import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/channel-runtime"; +import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; +import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; +import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { ensureConfiguredAcpRouteReady, resolveConfiguredAcpRoute, -} from "../../../../src/acp/persistent-bindings.route.js"; -import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; -import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; +} from "openclaw/plugin-sdk/conversation-runtime"; +import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; +import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; +import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import type { ChatCommandDefinition, CommandArgDefinition, CommandArgValues, CommandArgs, NativeCommandSpec, -} from "../../../../src/auto-reply/commands-registry.js"; +} from "openclaw/plugin-sdk/reply-runtime"; import { buildCommandTextFromArgs, findCommandByNativeName, @@ -36,25 +46,15 @@ import { resolveCommandArgChoices, resolveCommandArgMenu, serializeCommandArgs, -} from "../../../../src/auto-reply/commands-registry.js"; -import { resolveStoredModelOverride } from "../../../../src/auto-reply/reply/model-selection.js"; -import { dispatchReplyWithDispatcher } from "../../../../src/auto-reply/reply/provider-dispatcher.js"; -import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; -import { resolveNativeCommandSessionTargets } from "../../../../src/channels/native-command-session-targets.js"; -import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; -import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; -import { resolveOpenProviderRuntimeGroupPolicy } from "../../../../src/config/runtime-group-policy.js"; -import { loadSessionStore, resolveStorePath } from "../../../../src/config/sessions.js"; -import { logVerbose } from "../../../../src/globals.js"; -import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; -import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js"; -import { buildPairingReply } from "../../../../src/pairing/pairing-messages.js"; -import { executePluginCommand, matchPluginCommand } from "../../../../src/plugins/commands.js"; -import type { ResolvedAgentRoute } from "../../../../src/routing/resolve-route.js"; -import { chunkItems } from "../../../../src/utils/chunk-items.js"; -import { withTimeout } from "../../../../src/utils/with-timeout.js"; +} from "openclaw/plugin-sdk/reply-runtime"; +import { resolveStoredModelOverride } from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { chunkItems } from "openclaw/plugin-sdk/text-runtime"; +import { withTimeout } from "openclaw/plugin-sdk/text-runtime"; import { loadWebMedia } from "../../../whatsapp/src/media.js"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; diff --git a/extensions/discord/src/monitor/preflight-audio.ts b/extensions/discord/src/monitor/preflight-audio.ts index f52e2b0df93..f26fe5de9a9 100644 --- a/extensions/discord/src/monitor/preflight-audio.ts +++ b/extensions/discord/src/monitor/preflight-audio.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import { logVerbose } from "../../../../src/globals.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; type DiscordAudioAttachment = { content_type?: string; @@ -50,8 +50,7 @@ export async function resolveDiscordPreflightAudioMentionContext(params: { }; } try { - const { transcribeFirstAudio } = - await import("../../../../src/media-understanding/audio-preflight.js"); + const { transcribeFirstAudio } = await import("openclaw/plugin-sdk/media-runtime"); if (params.abortSignal?.aborted) { return { hasAudioAttachment, diff --git a/extensions/discord/src/monitor/presence.ts b/extensions/discord/src/monitor/presence.ts index b13a21dc2f1..cfe8125e50e 100644 --- a/extensions/discord/src/monitor/presence.ts +++ b/extensions/discord/src/monitor/presence.ts @@ -1,5 +1,5 @@ import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway"; -import type { DiscordAccountConfig } from "../../../../src/config/config.js"; +import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; const DEFAULT_CUSTOM_ACTIVITY_TYPE = 4; const CUSTOM_STATUS_NAME = "Custom Status"; diff --git a/extensions/discord/src/monitor/provider.allowlist.ts b/extensions/discord/src/monitor/provider.allowlist.ts index 3f108e443ea..ac6c89dd9f8 100644 --- a/extensions/discord/src/monitor/provider.allowlist.ts +++ b/extensions/discord/src/monitor/provider.allowlist.ts @@ -4,11 +4,11 @@ import { canonicalizeAllowlistWithResolvedIds, patchAllowlistUsersInConfigEntries, summarizeMapping, -} from "../../../../src/channels/allowlists/resolve-utils.js"; -import type { DiscordGuildEntry } from "../../../../src/config/types.discord.js"; -import { formatErrorMessage } from "../../../../src/infra/errors.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; -import { normalizeStringEntries } from "../../../../src/shared/string-normalization.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; +import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordChannelAllowlist } from "../resolve-channels.js"; import { resolveDiscordUserAllowlist } from "../resolve-users.js"; diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index 4d2130c3a5d..0d5fbd66b25 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -1,9 +1,9 @@ import type { Client } from "@buape/carbon"; import type { GatewayPlugin } from "@buape/carbon/gateway"; -import { createArmableStallWatchdog } from "../../../../src/channels/transport/stall-watchdog.js"; -import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js"; -import { danger } from "../../../../src/globals.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { createArmableStallWatchdog } from "openclaw/plugin-sdk/channel-runtime"; +import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { attachDiscordGatewayLogging } from "../gateway-logging.js"; import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js"; import type { DiscordVoiceManager } from "../voice/manager.js"; diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index d4ef01ab0d8..9c766334964 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -11,39 +11,45 @@ import { import { GatewayCloseCodes, type GatewayPlugin } from "@buape/carbon/gateway"; import { VoicePlugin } from "@buape/carbon/voice"; import { Routes } from "discord-api-types/v10"; -import { getAcpSessionManager } from "../../../../src/acp/control-plane/manager.js"; -import { isAcpRuntimeError } from "../../../../src/acp/runtime/errors.js"; -import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; -import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js"; -import { listNativeCommandSpecsForConfig } from "../../../../src/auto-reply/commands-registry.js"; -import type { HistoryEntry } from "../../../../src/auto-reply/reply/history.js"; -import { listSkillCommandsForAgents } from "../../../../src/auto-reply/skill-commands.js"; +import { getAcpSessionManager } from "openclaw/plugin-sdk/acp-runtime"; +import { isAcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime"; import { resolveThreadBindingIdleTimeoutMs, resolveThreadBindingMaxAgeMs, resolveThreadBindingsEnabled, -} from "../../../../src/channels/thread-bindings-policy.js"; +} from "openclaw/plugin-sdk/channel-runtime"; import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, -} from "../../../../src/config/commands.js"; -import type { OpenClawConfig, ReplyToMode } from "../../../../src/config/config.js"; -import { loadConfig } from "../../../../src/config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { GROUP_POLICY_BLOCKED_LABEL, resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, -} from "../../../../src/config/runtime-group-policy.js"; -import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js"; -import { danger, isVerbose, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js"; -import { formatErrorMessage } from "../../../../src/infra/errors.js"; -import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; -import { getPluginCommandSpecs } from "../../../../src/plugins/commands.js"; -import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; -import { summarizeStringEntries } from "../../../../src/shared/string-sample.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; +import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; +import { getPluginCommandSpecs } from "openclaw/plugin-sdk/plugin-runtime"; +import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; +import type { NativeCommandSpec } from "openclaw/plugin-sdk/reply-runtime"; +import { listNativeCommandSpecsForConfig } from "openclaw/plugin-sdk/reply-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/reply-runtime"; +import { + danger, + isVerbose, + logVerbose, + shouldLogVerbose, + warn, +} from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { summarizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordAccount } from "../accounts.js"; import { getDiscordGatewayEmitter } from "../monitor.gateway.js"; import { fetchDiscordApplicationId } from "../probe.js"; diff --git a/extensions/discord/src/monitor/reply-delivery.ts b/extensions/discord/src/monitor/reply-delivery.ts index 07e5c9e06c5..6e495d420ce 100644 --- a/extensions/discord/src/monitor/reply-delivery.ts +++ b/extensions/discord/src/monitor/reply-delivery.ts @@ -1,13 +1,17 @@ import type { RequestClient } from "@buape/carbon"; -import { resolveAgentAvatar } from "../../../../src/agents/identity-avatar.js"; -import type { ChunkMode } from "../../../../src/auto-reply/chunk.js"; -import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import type { MarkdownTableMode, ReplyToMode } from "../../../../src/config/types.base.js"; -import { createDiscordRetryRunner, type RetryRunner } from "../../../../src/infra/retry-policy.js"; -import { resolveRetryConfig, retryAsync, type RetryConfig } from "../../../../src/infra/retry.js"; -import { convertMarkdownTables } from "../../../../src/markdown/tables.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; +import { createDiscordRetryRunner, type RetryRunner } from "openclaw/plugin-sdk/infra-runtime"; +import { + resolveRetryConfig, + retryAsync, + type RetryConfig, +} from "openclaw/plugin-sdk/infra-runtime"; +import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordAccount } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { sendMessageDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord } from "../send.js"; diff --git a/extensions/discord/src/monitor/rest-fetch.ts b/extensions/discord/src/monitor/rest-fetch.ts index 83be5a98325..43b4c768381 100644 --- a/extensions/discord/src/monitor/rest-fetch.ts +++ b/extensions/discord/src/monitor/rest-fetch.ts @@ -1,7 +1,7 @@ +import { wrapFetchWithAbortSignal } from "openclaw/plugin-sdk/infra-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { ProxyAgent, fetch as undiciFetch } from "undici"; -import { danger } from "../../../../src/globals.js"; -import { wrapFetchWithAbortSignal } from "../../../../src/infra/fetch.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; export function resolveDiscordRestFetch( proxyUrl: string | undefined, diff --git a/extensions/discord/src/monitor/route-resolution.ts b/extensions/discord/src/monitor/route-resolution.ts index aacbebbd51e..f76c9b49f65 100644 --- a/extensions/discord/src/monitor/route-resolution.ts +++ b/extensions/discord/src/monitor/route-resolution.ts @@ -1,11 +1,11 @@ -import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { deriveLastRoutePolicy, resolveAgentRoute, type ResolvedAgentRoute, type RoutePeer, -} from "../../../../src/routing/resolve-route.js"; -import { resolveAgentIdFromSessionKey } from "../../../../src/routing/session-key.js"; +} from "openclaw/plugin-sdk/routing"; +import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; export function buildDiscordRoutePeer(params: { isDirectMessage: boolean; diff --git a/extensions/discord/src/monitor/thread-bindings.config.ts b/extensions/discord/src/monitor/thread-bindings.config.ts index 830d54d0d1b..701defcfbe1 100644 --- a/extensions/discord/src/monitor/thread-bindings.config.ts +++ b/extensions/discord/src/monitor/thread-bindings.config.ts @@ -2,9 +2,9 @@ import { resolveThreadBindingIdleTimeoutMs, resolveThreadBindingMaxAgeMs, resolveThreadBindingsEnabled, -} from "../../../../src/channels/thread-bindings-policy.js"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import { normalizeAccountId } from "../../../../src/routing/session-key.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; export { resolveThreadBindingIdleTimeoutMs, diff --git a/extensions/discord/src/monitor/thread-bindings.discord-api.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.ts index 134eda0f109..d144bb22b72 100644 --- a/extensions/discord/src/monitor/thread-bindings.discord-api.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.ts @@ -1,6 +1,6 @@ import { ChannelType, Routes } from "discord-api-types/v10"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import { logVerbose } from "../../../../src/globals.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createDiscordRestClient } from "../client.js"; import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js"; import { createThreadDiscord } from "../send.messages.js"; diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.ts index d7d96857250..230a9cd7273 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.ts @@ -1,9 +1,6 @@ -import { - readAcpSessionEntry, - type AcpSessionStoreEntry, -} from "../../../../src/acp/runtime/session-meta.js"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import { normalizeAccountId } from "../../../../src/routing/session-key.js"; +import { readAcpSessionEntry, type AcpSessionStoreEntry } from "openclaw/plugin-sdk/acp-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { parseDiscordTarget } from "../targets.js"; import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js"; import { getThreadBindingManager } from "./thread-bindings.manager.js"; diff --git a/extensions/discord/src/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts index efa599cadc2..f6d5f7d3d90 100644 --- a/extensions/discord/src/monitor/thread-bindings.manager.ts +++ b/extensions/discord/src/monitor/thread-bindings.manager.ts @@ -1,17 +1,14 @@ import { Routes } from "discord-api-types/v10"; -import { resolveThreadBindingConversationIdFromBindingId } from "../../../../src/channels/thread-binding-id.js"; -import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../../../../src/config/config.js"; -import { logVerbose } from "../../../../src/globals.js"; +import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime"; +import { getRuntimeConfigSnapshot, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { registerSessionBindingAdapter, unregisterSessionBindingAdapter, type BindingTargetKind, type SessionBindingRecord, -} from "../../../../src/infra/outbound/session-binding-service.js"; -import { - normalizeAccountId, - resolveAgentIdFromSessionKey, -} from "../../../../src/routing/session-key.js"; +} from "openclaw/plugin-sdk/conversation-runtime"; +import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createDiscordRestClient } from "../client.js"; import { createThreadForBinding, diff --git a/extensions/discord/src/monitor/thread-bindings.messages.ts b/extensions/discord/src/monitor/thread-bindings.messages.ts index 3fc122cbe71..043e888b7fc 100644 --- a/extensions/discord/src/monitor/thread-bindings.messages.ts +++ b/extensions/discord/src/monitor/thread-bindings.messages.ts @@ -3,4 +3,4 @@ export { resolveThreadBindingFarewellText, resolveThreadBindingIntroText, resolveThreadBindingThreadName, -} from "../../../../src/channels/thread-bindings-messages.js"; +} from "openclaw/plugin-sdk/channel-runtime"; diff --git a/extensions/discord/src/monitor/thread-bindings.persona.ts b/extensions/discord/src/monitor/thread-bindings.persona.ts index 6798df009e0..2ee38c5f49d 100644 --- a/extensions/discord/src/monitor/thread-bindings.persona.ts +++ b/extensions/discord/src/monitor/thread-bindings.persona.ts @@ -1,4 +1,4 @@ -import { SYSTEM_MARK } from "../../../../src/infra/system-message.js"; +import { SYSTEM_MARK } from "openclaw/plugin-sdk/infra-runtime"; import type { ThreadBindingRecord } from "./thread-bindings.types.js"; const THREAD_BINDING_PERSONA_MAX_CHARS = 80; diff --git a/extensions/discord/src/monitor/thread-bindings.state.ts b/extensions/discord/src/monitor/thread-bindings.state.ts index cfcbc65f3f5..97de19c1dd5 100644 --- a/extensions/discord/src/monitor/thread-bindings.state.ts +++ b/extensions/discord/src/monitor/thread-bindings.state.ts @@ -1,11 +1,8 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveStateDir } from "../../../../src/config/paths.js"; -import { loadJsonFile, saveJsonFile } from "../../../../src/infra/json-file.js"; -import { - normalizeAccountId, - resolveAgentIdFromSessionKey, -} from "../../../../src/routing/session-key.js"; +import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store"; +import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; import { DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS, DEFAULT_THREAD_BINDING_MAX_AGE_MS, diff --git a/extensions/discord/src/monitor/thread-session-close.ts b/extensions/discord/src/monitor/thread-session-close.ts index ca73f623bd0..6a5d6c88c8b 100644 --- a/extensions/discord/src/monitor/thread-session-close.ts +++ b/extensions/discord/src/monitor/thread-session-close.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import { resolveStorePath, updateSessionStore } from "../../../../src/config/sessions.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveStorePath, updateSessionStore } from "openclaw/plugin-sdk/config-runtime"; /** * Marks every session entry in the store whose key contains {@link threadId} diff --git a/extensions/discord/src/monitor/threading.ts b/extensions/discord/src/monitor/threading.ts index 035354b98af..c3bf70d659c 100644 --- a/extensions/discord/src/monitor/threading.ts +++ b/extensions/discord/src/monitor/threading.ts @@ -1,10 +1,10 @@ import { ChannelType, type Client } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; -import { createReplyReferencePlanner } from "../../../../src/auto-reply/reply/reply-reference.js"; -import type { ReplyToMode } from "../../../../src/config/config.js"; -import { logVerbose } from "../../../../src/globals.js"; -import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js"; -import { truncateUtf16Safe } from "../../../../src/utils.js"; +import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; +import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime"; +import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime"; import type { DiscordChannelConfigResolved } from "./allow-list.js"; import type { DiscordMessageEvent } from "./listeners.js"; import { diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index bc2f5f8c2d1..93fd1cb8bfb 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -2,11 +2,11 @@ import { resolvePayloadMediaUrls, sendPayloadMediaSequence, sendTextMediaPayload, -} from "../../../src/channels/plugins/outbound/direct-text-media.js"; -import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { OutboundIdentity } from "../../../src/infra/outbound/identity.js"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import type { DiscordComponentMessageSpec } from "./components.js"; import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js"; import { normalizeDiscordOutboundTarget } from "./normalize.js"; diff --git a/extensions/discord/src/plugin-shared.ts b/extensions/discord/src/plugin-shared.ts index 9b5aec43b9e..f67e04d1a51 100644 --- a/extensions/discord/src/plugin-shared.ts +++ b/extensions/discord/src/plugin-shared.ts @@ -1,9 +1,9 @@ +import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, - formatAllowFromLowercase, -} from "../../../src/plugin-sdk-internal/channel-config.js"; -import { type OpenClawConfig } from "../../../src/plugin-sdk-internal/discord.js"; +} from "openclaw/plugin-sdk/channel-config-helpers"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/discord"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, diff --git a/extensions/discord/src/pluralkit.ts b/extensions/discord/src/pluralkit.ts index e328fb27eff..b8e6b30609a 100644 --- a/extensions/discord/src/pluralkit.ts +++ b/extensions/discord/src/pluralkit.ts @@ -1,4 +1,4 @@ -import { resolveFetch } from "../../../src/infra/fetch.js"; +import { resolveFetch } from "openclaw/plugin-sdk/infra-runtime"; const PLURALKIT_API_BASE = "https://api.pluralkit.me/v2"; diff --git a/extensions/discord/src/probe.ts b/extensions/discord/src/probe.ts index b434cd8c78d..f84b4aad10a 100644 --- a/extensions/discord/src/probe.ts +++ b/extensions/discord/src/probe.ts @@ -1,6 +1,6 @@ -import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; -import { resolveFetch } from "../../../src/infra/fetch.js"; -import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveFetch } from "openclaw/plugin-sdk/infra-runtime"; +import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime"; import { normalizeDiscordToken } from "./token.js"; const DISCORD_API_BASE = "https://discord.com/api/v10"; diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index b73ec43a065..4a07abdc1f7 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -1,7 +1,5 @@ -import { - createPluginRuntimeStore, - type PluginRuntime, -} from "../../../src/plugin-sdk-internal/core.js"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } = createPluginRuntimeStore("Discord runtime not initialized"); diff --git a/extensions/discord/src/send.components.ts b/extensions/discord/src/send.components.ts index 9212e383ed7..9c641ba596d 100644 --- a/extensions/discord/src/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -5,8 +5,8 @@ import { type RequestClient, } from "@buape/carbon"; import { ChannelType, Routes } from "discord-api-types/v10"; -import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js"; -import { recordChannelActivity } from "../../../src/infra/channel-activity.js"; +import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { loadWebMedia } from "../../whatsapp/src/media.js"; import { resolveDiscordAccount } from "./accounts.js"; import { registerDiscordComponentEntries } from "./components-registry.js"; diff --git a/extensions/discord/src/send.outbound.ts b/extensions/discord/src/send.outbound.ts index 8f7b743e0d0..cc71330b192 100644 --- a/extensions/discord/src/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -3,17 +3,17 @@ import fs from "node:fs/promises"; import path from "node:path"; import { serializePayload, type MessagePayloadObject, type RequestClient } from "@buape/carbon"; import { ChannelType, Routes } from "discord-api-types/v10"; -import { resolveChunkMode } from "../../../src/auto-reply/chunk.js"; -import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js"; -import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; -import { recordChannelActivity } from "../../../src/infra/channel-activity.js"; -import type { RetryConfig } from "../../../src/infra/retry.js"; -import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js"; -import { convertMarkdownTables } from "../../../src/markdown/tables.js"; -import { maxBytesForKind } from "../../../src/media/constants.js"; -import { extensionForMime } from "../../../src/media/mime.js"; -import { unlinkIfExists } from "../../../src/media/temp-files.js"; -import type { PollInput } from "../../../src/polls.js"; +import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; +import type { RetryConfig } from "openclaw/plugin-sdk/infra-runtime"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime"; +import { maxBytesForKind } from "openclaw/plugin-sdk/media-runtime"; +import { extensionForMime } from "openclaw/plugin-sdk/media-runtime"; +import { unlinkIfExists } from "openclaw/plugin-sdk/media-runtime"; +import type { PollInput } from "openclaw/plugin-sdk/media-runtime"; +import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; +import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; import { loadWebMediaRaw } from "../../whatsapp/src/media.js"; import { resolveDiscordAccount } from "./accounts.js"; import { rewriteDiscordKnownMentions } from "./mentions.js"; diff --git a/extensions/discord/src/send.reactions.ts b/extensions/discord/src/send.reactions.ts index 26353a7acb5..be48c85771d 100644 --- a/extensions/discord/src/send.reactions.ts +++ b/extensions/discord/src/send.reactions.ts @@ -1,5 +1,5 @@ import { Routes } from "discord-api-types/v10"; -import { loadConfig } from "../../../src/config/config.js"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { buildReactionIdentifier, createDiscordClient, diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index f1a7fd4c28e..115356510d2 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -9,15 +9,15 @@ import { import { PollLayoutType } from "discord-api-types/payloads/v10"; import type { RESTAPIPoll } from "discord-api-types/rest/v10"; import { Routes, type APIChannel, type APIEmbed } from "discord-api-types/v10"; -import type { ChunkMode } from "../../../src/auto-reply/chunk.js"; -import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js"; -import type { RetryRunner } from "../../../src/infra/retry-policy.js"; -import { buildOutboundMediaLoadOptions } from "../../../src/media/load-options.js"; +import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { RetryRunner } from "openclaw/plugin-sdk/infra-runtime"; +import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime"; import { normalizePollDurationHours, normalizePollInput, type PollInput, -} from "../../../src/polls.js"; +} from "openclaw/plugin-sdk/media-runtime"; +import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { loadWebMedia } from "../../whatsapp/src/media.js"; import { resolveDiscordAccount } from "./accounts.js"; import { chunkDiscordTextWithMode } from "./chunk.js"; diff --git a/extensions/discord/src/send.test-harness.ts b/extensions/discord/src/send.test-harness.ts index f3c5ae36842..8a2058772fc 100644 --- a/extensions/discord/src/send.test-harness.ts +++ b/extensions/discord/src/send.test-harness.ts @@ -1,5 +1,5 @@ +import type { MockFn } from "openclaw/plugin-sdk/test-utils"; import { vi } from "vitest"; -import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; type DiscordWebMediaMockFactoryResult = { loadWebMedia: MockFn; diff --git a/extensions/discord/src/send.types.ts b/extensions/discord/src/send.types.ts index 189c9434d1e..781cb84a435 100644 --- a/extensions/discord/src/send.types.ts +++ b/extensions/discord/src/send.types.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { RetryConfig } from "../../../src/infra/retry.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { RetryConfig } from "openclaw/plugin-sdk/infra-runtime"; export class DiscordSendError extends Error { kind?: "missing-permissions" | "dm-blocked"; diff --git a/extensions/discord/src/session-key-normalization.ts b/extensions/discord/src/session-key-normalization.ts index 7e47fe012dd..06164d6aba5 100644 --- a/extensions/discord/src/session-key-normalization.ts +++ b/extensions/discord/src/session-key-normalization.ts @@ -1,5 +1,5 @@ -import type { MsgContext } from "../../../src/auto-reply/templating.js"; -import { normalizeChatType } from "../../../src/channels/chat-type.js"; +import { normalizeChatType } from "openclaw/plugin-sdk/channel-runtime"; +import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; export function normalizeExplicitDiscordSessionKey( sessionKey: string, diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index fe2b559a975..a362824a0f3 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,7 +1,10 @@ -import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; -import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; +import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; import { + applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, + formatDocsLink, + migrateBaseNameToDefaultAccount, + normalizeAccountId, noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, @@ -9,13 +12,12 @@ import { setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, type OpenClawConfig, -} from "../../../src/plugin-sdk-internal/setup.js"; +} from "openclaw/plugin-sdk/setup"; import { type ChannelSetupAdapter, type ChannelSetupDmPolicy, type ChannelSetupWizard, -} from "../../../src/plugin-sdk-internal/setup.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +} from "openclaw/plugin-sdk/setup"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js"; @@ -70,8 +72,15 @@ export function parseDiscordAllowFromId(value: string): string | null { }); } -export const discordSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ - channelKey: channel, +export const discordSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "DISCORD_BOT_TOKEN can only be used for the default account."; @@ -81,46 +90,57 @@ export const discordSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetu } return null; }, - buildPatch: (input) => (input.useEnv ? {} : input.token ? { token: input.token } : {}), -}); - -type DiscordAllowFromResolverParams = { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { token?: string }; - entries: string[]; + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + discord: { + ...next.channels?.discord, + enabled: true, + ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + discord: { + ...next.channels?.discord, + enabled: true, + accounts: { + ...next.channels?.discord?.accounts, + [accountId]: { + ...next.channels?.discord?.accounts?.[accountId], + enabled: true, + ...(input.token ? { token: input.token } : {}), + }, + }, + }, + }, + }; + }, }; -type DiscordGroupAllowlistResolverParams = DiscordAllowFromResolverParams & { - prompter: { note: (message: string, title?: string) => Promise }; -}; - -type DiscordGroupAllowlistResolution = Array<{ - input: string; - resolved: boolean; -}>; - -type DiscordSetupWizardHandlers = { - promptAllowFrom: (params: { - cfg: OpenClawConfig; - prompter: import("../../../src/plugin-sdk-internal/setup.js").WizardPrompter; - accountId?: string; - }) => Promise; - resolveAllowFromEntries: (params: DiscordAllowFromResolverParams) => Promise< - Array<{ - input: string; - resolved: boolean; - id: string | null; - }> - >; - resolveGroupAllowlist: ( - params: DiscordGroupAllowlistResolverParams, - ) => Promise; -}; - -export function createDiscordSetupWizardBase( - handlers: DiscordSetupWizardHandlers, -): ChannelSetupWizard { +export function createDiscordSetupWizardProxy( + loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, +) { const discordDmPolicy: ChannelSetupDmPolicy = { label: "Discord", channel, @@ -134,7 +154,13 @@ export function createDiscordSetupWizardBase( channel, dmPolicy: policy, }), - promptAllowFrom: handlers.promptAllowFrom, + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, }; return { @@ -212,22 +238,44 @@ export function createDiscordSetupWizardBase( accountId, patch: { groupPolicy: policy }, }), - resolveAllowlist: async (params: DiscordGroupAllowlistResolverParams) => { + resolveAllowlist: async ({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { token?: string }; + entries: string[]; + prompter: { note: (message: string, title?: string) => Promise }; + }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.groupAccess?.resolveAllowlist) { + return entries.map((input) => ({ input, resolved: false })); + } try { - return await handlers.resolveGroupAllowlist(params); + return await wizard.groupAccess.resolveAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }); } catch (error) { await noteChannelLookupFailure({ - prompter: params.prompter, + prompter, label: "Discord channels", error, }); await noteChannelLookupSummary({ - prompter: params.prompter, + prompter, label: "Discord channels", resolvedSections: [], - unresolved: params.entries, + unresolved: entries, }); - return params.entries.map((input) => ({ input, resolved: false })); + return entries.map((input) => ({ input, resolved: false })); } }, applyAllowlist: ({ @@ -257,7 +305,28 @@ export function createDiscordSetupWizardBase( invalidWithoutCredentialNote: "Bot token missing; use numeric user ids (or mention form) only.", parseId: parseDiscordAllowFromId, - resolveEntries: handlers.resolveAllowFromEntries, + resolveEntries: async ({ + cfg, + accountId, + credentialValues, + entries, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { token?: string }; + entries: string[]; + }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, apply: async ({ cfg, accountId, @@ -278,42 +347,3 @@ export function createDiscordSetupWizardBase( disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } - -export function createDiscordSetupWizardProxy( - loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, -) { - return createDiscordSetupWizardBase({ - promptAllowFrom: async ({ cfg, prompter, accountId }) => { - const wizard = (await loadWizard()).discordSetupWizard; - if (!wizard.dmPolicy?.promptAllowFrom) { - return cfg; - } - return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); - }, - resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => { - const wizard = (await loadWizard()).discordSetupWizard; - if (!wizard.allowFrom) { - return entries.map((input) => ({ input, resolved: false, id: null })); - } - return await wizard.allowFrom.resolveEntries({ - cfg, - accountId, - credentialValues, - entries, - }); - }, - resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { - const wizard = (await loadWizard()).discordSetupWizard; - if (!wizard.groupAccess?.resolveAllowlist) { - return entries.map((input) => ({ input, resolved: false })); - } - return (await wizard.groupAccess.resolveAllowlist({ - cfg, - accountId, - credentialValues, - entries, - prompter, - })) as DiscordGroupAllowlistResolution; - }, - }); -} diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index 5f785db6f01..da87bfd77d0 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,14 +1,24 @@ import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, noteChannelLookupFailure, noteChannelLookupSummary, type OpenClawConfig, + parseMentionOrPrefixedId, + patchChannelConfigForAccount, promptLegacyChannelAllowFrom, resolveSetupAccountId, + setLegacyChannelDmPolicyWithAllowFrom, + setSetupChannelEnabled, type WizardPrompter, -} from "../../../src/plugin-sdk-internal/setup.js"; -import { type ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js"; +} from "openclaw/plugin-sdk/setup"; +import { type ChannelSetupDmPolicy, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, +} from "./accounts.js"; import { normalizeDiscordSlug } from "./monitor/allow-list.js"; import { resolveDiscordChannelAllowlist, @@ -16,7 +26,6 @@ import { } from "./resolve-channels.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { - createDiscordSetupWizardBase, discordSetupAdapter, DISCORD_TOKEN_HELP_LINES, parseDiscordAllowFromId, @@ -82,62 +91,186 @@ async function promptDiscordAllowFrom(params: { }); } -export const discordSetupWizard: ChannelSetupWizard = createDiscordSetupWizardBase({ - promptAllowFrom: promptDiscordAllowFrom, - resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => - await resolveDiscordAllowFromEntries({ - token: - resolveDiscordAccount({ cfg, accountId }).token || - (typeof credentialValues.token === "string" ? credentialValues.token : ""), - entries, +const discordDmPolicy: ChannelSetupDmPolicy = { + label: "Discord", + channel, + policyKey: "channels.discord.dmPolicy", + allowFromKey: "channels.discord.allowFrom", + getCurrent: (cfg) => + cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing", + setPolicy: (cfg, policy) => + setLegacyChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, }), - resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { - const token = - resolveDiscordAccount({ cfg, accountId }).token || - (typeof credentialValues.token === "string" ? credentialValues.token : ""); - let resolved: DiscordChannelResolution[] = entries.map((input) => ({ - input, - resolved: false, - })); - if (!token || entries.length === 0) { - return resolved; - } - try { - resolved = await resolveDiscordChannelAllowlist({ - token, - entries, - }); - const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId); - const resolvedGuilds = resolved.filter( - (entry) => entry.resolved && entry.guildId && !entry.channelId, - ); - const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); - await noteChannelLookupSummary({ - prompter, - label: "Discord channels", - resolvedSections: [ - { - title: "Resolved channels", - values: resolvedChannels - .map((entry) => entry.channelId) - .filter((value): value is string => Boolean(value)), - }, - { - title: "Resolved guilds", - values: resolvedGuilds - .map((entry) => entry.guildId) - .filter((value): value is string => Boolean(value)), - }, - ], - unresolved, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: "Discord channels", - error, - }); - } - return resolved; + promptAllowFrom: promptDiscordAllowFrom, +}; + +export const discordSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token", + configuredHint: "configured", + unconfiguredHint: "needs token", + configuredScore: 2, + unconfiguredScore: 1, + resolveConfigured: ({ cfg }) => + listDiscordAccountIds(cfg).some( + (accountId) => inspectDiscordAccount({ cfg, accountId }).configured, + ), }, -}); + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "Discord bot token", + preferredEnvVar: "DISCORD_BOT_TOKEN", + helpTitle: "Discord bot token", + helpLines: DISCORD_TOKEN_HELP_LINES, + envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?", + keepPrompt: "Discord token already configured. Keep it?", + inputPrompt: "Enter Discord bot token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const account = inspectDiscordAccount({ cfg, accountId }); + return { + accountConfigured: account.configured, + hasConfiguredValue: account.tokenStatus !== "missing", + resolvedValue: account.token?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.DISCORD_BOT_TOKEN?.trim() || undefined + : undefined, + }; + }, + }, + ], + groupAccess: { + label: "Discord channels", + placeholder: "My Server/#general, guildId/channelId, #support", + currentPolicy: ({ cfg, accountId }) => + resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }) => + Object.entries(resolveDiscordAccount({ cfg, accountId }).config.guilds ?? {}).flatMap( + ([guildKey, value]) => { + const channels = value?.channels ?? {}; + const channelKeys = Object.keys(channels); + if (channelKeys.length === 0) { + const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey; + return [input]; + } + return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`); + }, + ), + updatePrompt: ({ cfg, accountId }) => + Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds), + setPolicy: ({ cfg, accountId, policy }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { groupPolicy: policy }, + }), + resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + const token = + resolveDiscordAccount({ cfg, accountId }).token || + (typeof credentialValues.token === "string" ? credentialValues.token : ""); + let resolved: DiscordChannelResolution[] = entries.map((input) => ({ + input, + resolved: false, + })); + if (!token || entries.length === 0) { + return resolved; + } + try { + resolved = await resolveDiscordChannelAllowlist({ + token, + entries, + }); + const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId); + const resolvedGuilds = resolved.filter( + (entry) => entry.resolved && entry.guildId && !entry.channelId, + ); + const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); + await noteChannelLookupSummary({ + prompter, + label: "Discord channels", + resolvedSections: [ + { + title: "Resolved channels", + values: resolvedChannels + .map((entry) => entry.channelId) + .filter((value): value is string => Boolean(value)), + }, + { + title: "Resolved guilds", + values: resolvedGuilds + .map((entry) => entry.guildId) + .filter((value): value is string => Boolean(value)), + }, + ], + unresolved, + }); + } catch (error) { + await noteChannelLookupFailure({ + prompter, + label: "Discord channels", + error, + }); + } + return resolved; + }, + applyAllowlist: ({ cfg, accountId, resolved }) => { + const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = []; + for (const entry of resolved as DiscordChannelResolution[]) { + const guildKey = + entry.guildId ?? + (entry.guildName ? normalizeDiscordSlug(entry.guildName) : undefined) ?? + "*"; + const channelKey = + entry.channelId ?? + (entry.channelName ? normalizeDiscordSlug(entry.channelName) : undefined); + if (!channelKey && guildKey === "*") { + continue; + } + allowlistEntries.push({ guildKey, ...(channelKey ? { channelKey } : {}) }); + } + return setDiscordGuildChannelAllowlist(cfg, accountId, allowlistEntries); + }, + }, + allowFrom: { + credentialInputKey: "token", + helpTitle: "Discord allowlist", + helpLines: [ + "Allowlist Discord DMs by username (we resolve to user ids).", + "Examples:", + "- 123456789012345678", + "- @alice", + "- alice#1234", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/discord", "discord")}`, + ], + message: "Discord allowFrom (usernames or ids)", + placeholder: "@alice, 123456789012345678", + invalidWithoutCredentialNote: "Bot token missing; use numeric user ids (or mention form) only.", + parseId: parseDiscordAllowFromId, + resolveEntries: async ({ cfg, accountId, credentialValues, entries }) => + await resolveDiscordAllowFromEntries({ + token: + resolveDiscordAccount({ cfg, accountId }).token || + (typeof credentialValues.token === "string" ? credentialValues.token : ""), + entries, + }), + apply: async ({ cfg, accountId, allowFrom }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + dmPolicy: discordDmPolicy, + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), +}; diff --git a/extensions/discord/src/shared-interactive.ts b/extensions/discord/src/shared-interactive.ts index d99f964f5c9..bb8bf1dac70 100644 --- a/extensions/discord/src/shared-interactive.ts +++ b/extensions/discord/src/shared-interactive.ts @@ -1,5 +1,5 @@ -import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js"; -import type { InteractiveButtonStyle, InteractiveReply } from "../../../src/interactive/payload.js"; +import { reduceInteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; +import type { InteractiveButtonStyle, InteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; import type { DiscordComponentButtonStyle, DiscordComponentMessageSpec } from "./components.js"; function resolveDiscordInteractiveButtonStyle( diff --git a/extensions/discord/src/status-issues.ts b/extensions/discord/src/status-issues.ts index baf2551c0f8..4fa26fd011b 100644 --- a/extensions/discord/src/status-issues.ts +++ b/extensions/discord/src/status-issues.ts @@ -3,11 +3,11 @@ import { asString, isRecord, resolveEnabledConfiguredAccountId, -} from "../../../src/channels/plugins/status-issues/shared.js"; +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelAccountSnapshot, ChannelStatusIssue, -} from "../../../src/channels/plugins/types.js"; +} from "openclaw/plugin-sdk/channel-runtime"; type DiscordIntentSummary = { messageContent?: "enabled" | "limited" | "disabled"; diff --git a/extensions/discord/src/subagent-hooks.ts b/extensions/discord/src/subagent-hooks.ts index fa45eadd7c2..c9ba7b97984 100644 --- a/extensions/discord/src/subagent-hooks.ts +++ b/extensions/discord/src/subagent-hooks.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "../../../src/plugin-sdk-internal/core.js"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { resolveDiscordAccount } from "./accounts.js"; import { autoBindSpawnedDiscordSubagent, diff --git a/extensions/discord/src/targets.ts b/extensions/discord/src/targets.ts index 198660dceff..3660f75921e 100644 --- a/extensions/discord/src/targets.ts +++ b/extensions/discord/src/targets.ts @@ -1,4 +1,4 @@ -import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; +import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime"; import { buildMessagingTarget, parseMentionPrefixOrAtUserTarget, @@ -6,7 +6,7 @@ import { type MessagingTarget, type MessagingTargetKind, type MessagingTargetParseOptions, -} from "../../../src/channels/targets.js"; +} from "openclaw/plugin-sdk/channel-runtime"; import { rememberDiscordDirectoryUser } from "./directory-cache.js"; import { listDiscordDirectoryPeersLive } from "./directory-live.js"; diff --git a/extensions/discord/src/token.ts b/extensions/discord/src/token.ts index aff802f3ded..2a979ca4b3b 100644 --- a/extensions/discord/src/token.ts +++ b/extensions/discord/src/token.ts @@ -1,7 +1,7 @@ -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"; +import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; export type DiscordTokenSource = "env" | "config" | "none"; diff --git a/extensions/discord/src/ui.ts b/extensions/discord/src/ui.ts index ed4cc9d4fa6..50f818f1471 100644 --- a/extensions/discord/src/ui.ts +++ b/extensions/discord/src/ui.ts @@ -1,5 +1,5 @@ import { Container } from "@buape/carbon"; -import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { inspectDiscordAccount } from "./account-inspect.js"; const DEFAULT_DISCORD_ACCENT_COLOR = "#5865F2"; diff --git a/extensions/discord/src/voice-message.ts b/extensions/discord/src/voice-message.ts index 6f77ebc7bd9..ea014f5f59e 100644 --- a/extensions/discord/src/voice-message.ts +++ b/extensions/discord/src/voice-message.ts @@ -14,15 +14,15 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { RateLimitError, type RequestClient } from "@buape/carbon"; -import type { RetryRunner } from "../../../src/infra/retry-policy.js"; -import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js"; +import type { RetryRunner } from "openclaw/plugin-sdk/infra-runtime"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime"; import { parseFfprobeCodecAndSampleRate, runFfmpeg, runFfprobe, -} from "../../../src/media/ffmpeg-exec.js"; -import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "../../../src/media/ffmpeg-limits.js"; -import { unlinkIfExists } from "../../../src/media/temp-files.js"; +} from "openclaw/plugin-sdk/media-runtime"; +import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "openclaw/plugin-sdk/media-runtime"; +import { unlinkIfExists } from "openclaw/plugin-sdk/media-runtime"; const DISCORD_VOICE_MESSAGE_FLAG = 1 << 13; const SUPPRESS_NOTIFICATIONS_FLAG = 1 << 12; diff --git a/extensions/discord/src/voice/command.ts b/extensions/discord/src/voice/command.ts index 26ef7b9bbe5..3ed7aa2ccdb 100644 --- a/extensions/discord/src/voice/command.ts +++ b/extensions/discord/src/voice/command.ts @@ -10,10 +10,10 @@ import { ChannelType as DiscordChannelType, type APIApplicationCommandChannelOption, } from "discord-api-types/v10"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; -import type { DiscordAccountConfig } from "../../../../src/config/types.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; +import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; import { formatMention } from "../mentions.js"; import { isDiscordGroupAllowedByPolicy, diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index a9f8d0fd721..c2fbcbfc686 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -16,20 +16,30 @@ import { type AudioPlayer, type VoiceConnection, } from "@discordjs/voice"; -import { resolveAgentDir } from "../../../../src/agents/agent-scope.js"; -import { agentCommandFromIngress } from "../../../../src/commands/agent.js"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; -import type { DiscordAccountConfig, TtsConfig } from "../../../../src/config/types.js"; -import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; -import { formatErrorMessage } from "../../../../src/infra/errors.js"; -import { resolvePreferredOpenClawTmpDir } from "../../../../src/infra/tmp-openclaw-dir.js"; -import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; -import { transcribeAudioFile } from "../../../../src/media-understanding/runtime.js"; -import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; -import { parseTtsDirectives } from "../../../../src/tts/tts-core.js"; -import { resolveTtsConfig, textToSpeech, type ResolvedTtsConfig } from "../../../../src/tts/tts.js"; +import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; +import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime"; +import { + resolveTtsConfig, + textToSpeech, + type ResolvedTtsConfig, +} from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; +import type { DiscordAccountConfig, TtsConfig } from "openclaw/plugin-sdk/config-runtime"; +import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime"; +import { + buildProviderRegistry, + createMediaAttachmentCache, + normalizeMediaAttachments, + runCapability, +} from "openclaw/plugin-sdk/media-runtime"; +import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { parseTtsDirectives } from "openclaw/plugin-sdk/speech"; import { formatMention } from "../mentions.js"; import { resolveDiscordOwnerAccess } from "../monitor/allow-list.js"; import { formatDiscordUserTag } from "../monitor/format.js"; @@ -230,13 +240,33 @@ async function transcribeAudio(params: { agentId: string; filePath: string; }): Promise { - const result = await transcribeAudioFile({ - cfg: params.cfg, - filePath: params.filePath, - mime: "audio/wav", - agentDir: resolveAgentDir(params.cfg, params.agentId), - }); - return result.text?.trim() || undefined; + const ctx: MsgContext = { + MediaPath: params.filePath, + MediaType: "audio/wav", + }; + const attachments = normalizeMediaAttachments(ctx); + if (attachments.length === 0) { + return undefined; + } + const cache = createMediaAttachmentCache(attachments); + const providerRegistry = buildProviderRegistry(); + try { + const result = await runCapability({ + capability: "audio", + cfg: params.cfg, + ctx, + attachments: cache, + media: attachments, + agentDir: resolveAgentDir(params.cfg, params.agentId), + providerRegistry, + config: params.cfg.tools?.media?.audio, + }); + const output = result.outputs.find((entry) => entry.kind === "audio.transcription"); + const text = output?.text?.trim(); + return text || undefined; + } finally { + await cache.cleanup(); + } } export class DiscordVoiceManager { diff --git a/extensions/elevenlabs/index.ts b/extensions/elevenlabs/index.ts index 49d792df20f..034c56815c3 100644 --- a/extensions/elevenlabs/index.ts +++ b/extensions/elevenlabs/index.ts @@ -1,5 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildElevenLabsSpeechProvider } from "../../src/tts/providers/elevenlabs.js"; +import { buildElevenLabsSpeechProvider } from "openclaw/plugin-sdk/speech"; const elevenLabsPlugin = { id: "elevenlabs", diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 728bb9a8ffc..6181d32f4af 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1,3 +1,8 @@ +import { + ensureConfiguredAcpRouteReady, + resolveConfiguredAcpRoute, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime"; import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; import { buildAgentMediaPayload, @@ -14,13 +19,8 @@ import { resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/feishu"; -import { - ensureConfiguredAcpRouteReady, - resolveConfiguredAcpRoute, -} from "../../../src/acp/persistent-bindings.route.js"; -import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js"; -import { deriveLastRoutePolicy } from "../../../src/routing/resolve-route.js"; -import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js"; +import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; +import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { buildFeishuConversationId } from "./conversation-id.js"; diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index b7888b7069e..617bc504756 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -2,7 +2,7 @@ import fs from "fs"; import path from "path"; import { Readable } from "stream"; import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; -import { mediaKindFromMime } from "../../../src/media/constants.js"; +import { mediaKindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; diff --git a/extensions/feishu/src/thread-bindings.ts b/extensions/feishu/src/thread-bindings.ts index b2ab72467c3..cfae8fb2058 100644 --- a/extensions/feishu/src/thread-bindings.ts +++ b/extensions/feishu/src/thread-bindings.ts @@ -1,20 +1,17 @@ -import { resolveThreadBindingConversationIdFromBindingId } from "../../../src/channels/thread-binding-id.js"; +import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime"; import { resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, -} from "../../../src/channels/thread-bindings-policy.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { registerSessionBindingAdapter, unregisterSessionBindingAdapter, type BindingTargetKind, type SessionBindingRecord, -} from "../../../src/infra/outbound/session-binding-service.js"; -import { - normalizeAccountId, - resolveAgentIdFromSessionKey, -} from "../../../src/routing/session-key.js"; -import { resolveGlobalSingleton } from "../../../src/shared/global-singleton.js"; +} from "openclaw/plugin-sdk/conversation-runtime"; +import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; +import { resolveGlobalSingleton } from "openclaw/plugin-sdk/text-runtime"; type FeishuBindingTargetKind = "subagent" | "acp"; diff --git a/extensions/firecrawl/index.ts b/extensions/firecrawl/index.ts index 42bd1a3252f..6b38ac6dc75 100644 --- a/extensions/firecrawl/index.ts +++ b/extensions/firecrawl/index.ts @@ -1,6 +1,8 @@ -import type { AnyAgentTool } from "../../src/agents/tools/common.js"; -import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; -import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +import { + emptyPluginConfigSchema, + type AnyAgentTool, + type OpenClawPluginApi, +} from "openclaw/plugin-sdk/core"; import { createFirecrawlScrapeTool } from "./src/firecrawl-scrape-tool.js"; import { createFirecrawlWebSearchProvider } from "./src/firecrawl-search-provider.js"; import { createFirecrawlSearchTool } from "./src/firecrawl-search-tool.js"; diff --git a/extensions/firecrawl/src/config.ts b/extensions/firecrawl/src/config.ts index 808b81891f1..5558c0dce0a 100644 --- a/extensions/firecrawl/src/config.ts +++ b/extensions/firecrawl/src/config.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { normalizeResolvedSecretInputString } from "../../../src/config/types.secrets.js"; -import { normalizeSecretInput } from "../../../src/utils/normalize-secret-input.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeSecretInput } from "openclaw/plugin-sdk/provider-auth"; export const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev"; export const DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS = 30; diff --git a/extensions/firecrawl/src/firecrawl-client.ts b/extensions/firecrawl/src/firecrawl-client.ts index 2929f2f9dde..18500d81c14 100644 --- a/extensions/firecrawl/src/firecrawl-client.ts +++ b/extensions/firecrawl/src/firecrawl-client.ts @@ -1,5 +1,6 @@ -import { markdownToText, truncateText } from "../../../src/agents/tools/web-fetch-utils.js"; -import { withTrustedWebToolsEndpoint } from "../../../src/agents/tools/web-guarded-fetch.js"; +import { markdownToText, truncateText } from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { withTrustedWebToolsEndpoint } from "openclaw/plugin-sdk/provider-web-search"; import { DEFAULT_CACHE_TTL_MINUTES, normalizeCacheKey, @@ -7,9 +8,8 @@ import { readResponseText, resolveCacheTtlMs, writeCache, -} from "../../../src/agents/tools/web-shared.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { wrapExternalContent, wrapWebContent } from "../../../src/security/external-content.js"; +} from "openclaw/plugin-sdk/provider-web-search"; +import { wrapExternalContent, wrapWebContent } from "openclaw/plugin-sdk/security-runtime"; import { resolveFirecrawlApiKey, resolveFirecrawlBaseUrl, diff --git a/extensions/firecrawl/src/firecrawl-scrape-tool.ts b/extensions/firecrawl/src/firecrawl-scrape-tool.ts index 509b3d5fbd6..70f0691d3d7 100644 --- a/extensions/firecrawl/src/firecrawl-scrape-tool.ts +++ b/extensions/firecrawl/src/firecrawl-scrape-tool.ts @@ -1,7 +1,7 @@ import { Type } from "@sinclair/typebox"; -import { optionalStringEnum } from "../../../src/agents/schema/typebox.js"; -import { jsonResult, readNumberParam, readStringParam } from "../../../src/agents/tools/common.js"; -import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; +import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime"; +import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; import { runFirecrawlScrape } from "./firecrawl-client.js"; const FirecrawlScrapeToolSchema = Type.Object( diff --git a/extensions/firecrawl/src/firecrawl-search-provider.ts b/extensions/firecrawl/src/firecrawl-search-provider.ts index 60489e9618e..0940aedb74d 100644 --- a/extensions/firecrawl/src/firecrawl-search-provider.ts +++ b/extensions/firecrawl/src/firecrawl-search-provider.ts @@ -1,5 +1,5 @@ import { Type } from "@sinclair/typebox"; -import type { WebSearchProviderPlugin } from "../../../src/plugins/types.js"; +import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/plugin-runtime"; import { runFirecrawlSearch } from "./firecrawl-client.js"; const GenericFirecrawlSearchSchema = Type.Object( diff --git a/extensions/firecrawl/src/firecrawl-search-tool.ts b/extensions/firecrawl/src/firecrawl-search-tool.ts index f2f133fd7ec..9a1201ec6e0 100644 --- a/extensions/firecrawl/src/firecrawl-search-tool.ts +++ b/extensions/firecrawl/src/firecrawl-search-tool.ts @@ -4,8 +4,8 @@ import { readNumberParam, readStringArrayParam, readStringParam, -} from "../../../src/agents/tools/common.js"; -import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; +} from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; import { runFirecrawlSearch } from "./firecrawl-client.js"; const FirecrawlSearchToolSchema = Type.Object( diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 8dadad31903..45f964c60f0 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -5,11 +5,13 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles.js"; -import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"; -import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { coerceSecretRef } from "../../src/config/types.secrets.js"; -import { githubCopilotLoginCommand } from "../../src/providers/github-copilot-auth.js"; +import { + coerceSecretRef, + ensureAuthProfileStore, + githubCopilotLoginCommand, + listProfilesForProvider, +} from "openclaw/plugin-sdk/provider-auth"; +import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js"; import { fetchCopilotUsage } from "./usage.js"; diff --git a/extensions/github-copilot/token.ts b/extensions/github-copilot/token.ts index afb1eb03b61..f743cf8bb88 100644 --- a/extensions/github-copilot/token.ts +++ b/extensions/github-copilot/token.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import { resolveStateDir } from "../../src/config/paths.js"; -import { loadJsonFile, saveJsonFile } from "../../src/infra/json-file.js"; +import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; diff --git a/extensions/github-copilot/usage.ts b/extensions/github-copilot/usage.ts index 9035027890c..1e13717c9ea 100644 --- a/extensions/github-copilot/usage.ts +++ b/extensions/github-copilot/usage.ts @@ -1,9 +1,11 @@ import { buildUsageHttpErrorSnapshot, fetchJson, -} from "../../src/infra/provider-usage.fetch.shared.js"; -import { clampPercent, PROVIDER_LABELS } from "../../src/infra/provider-usage.shared.js"; -import type { ProviderUsageSnapshot, UsageWindow } from "../../src/infra/provider-usage.types.js"; + clampPercent, + PROVIDER_LABELS, + type ProviderUsageSnapshot, + type UsageWindow, +} from "openclaw/plugin-sdk/provider-usage"; type CopilotUsageResponse = { quota_snapshots?: { diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index e235a0dfebc..6db7561a10b 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -1,10 +1,10 @@ import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/core"; -import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js"; import type { OpenClawPluginApi, ProviderAuthContext, ProviderFetchUsageSnapshotContext, -} from "../../src/plugins/types.js"; +} from "openclaw/plugin-sdk/core"; +import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage"; import { loginGeminiCliOAuth } from "./oauth.js"; import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 6389dd25e48..d310d8183a9 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -1,15 +1,14 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { + GOOGLE_GEMINI_DEFAULT_MODEL, + applyGoogleGeminiModelDefault, +} from "openclaw/plugin-sdk/provider-models"; import { createPluginBackedWebSearchProvider, getScopedCredentialValue, setScopedCredentialValue, -} from "../../src/agents/tools/web-search-plugin-factory.js"; -import { - GOOGLE_GEMINI_DEFAULT_MODEL, - applyGoogleGeminiModelDefault, -} from "../../src/commands/google-gemini-model-default.js"; -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"; +} from "openclaw/plugin-sdk/provider-web-search"; import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; import { googleMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; diff --git a/extensions/google/oauth.flow.ts b/extensions/google/oauth.flow.ts index 00cab07dc68..1ac7f260723 100644 --- a/extensions/google/oauth.flow.ts +++ b/extensions/google/oauth.flow.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes } from "node:crypto"; import { createServer } from "node:http"; -import { isWSL2Sync } from "../../src/infra/wsl.js"; +import { isWSL2Sync } from "openclaw/plugin-sdk/infra-runtime"; import { resolveOAuthClientConfig } from "./oauth.credentials.js"; import { AUTH_URL, REDIRECT_URI, SCOPES } from "./oauth.shared.js"; diff --git a/extensions/google/oauth.http.ts b/extensions/google/oauth.http.ts index 6c07c447143..3dcbd086b1c 100644 --- a/extensions/google/oauth.http.ts +++ b/extensions/google/oauth.http.ts @@ -1,4 +1,4 @@ -import { fetchWithSsrFGuard } from "../../src/infra/net/fetch-guard.js"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/infra-runtime"; import { DEFAULT_FETCH_TIMEOUT_MS } from "./oauth.shared.js"; export async function fetchWithTimeout( diff --git a/extensions/google/provider-models.ts b/extensions/google/provider-models.ts index 0a086780b1a..eddda4a9f9a 100644 --- a/extensions/google/provider-models.ts +++ b/extensions/google/provider-models.ts @@ -1,8 +1,8 @@ -import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, -} from "../../src/plugins/types.js"; +} from "openclaw/plugin-sdk/core"; +import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; diff --git a/extensions/huggingface/index.ts b/extensions/huggingface/index.ts index 433223bf268..c0c65f0051b 100644 --- a/extensions/huggingface/index.ts +++ b/extensions/huggingface/index.ts @@ -1,5 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyHuggingfaceConfig, HUGGINGFACE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildHuggingfaceProvider } from "./provider-catalog.js"; diff --git a/extensions/huggingface/onboard.ts b/extensions/huggingface/onboard.ts index 22493f87f0b..40df946abe3 100644 --- a/extensions/huggingface/onboard.ts +++ b/extensions/huggingface/onboard.ts @@ -2,12 +2,12 @@ import { buildHuggingfaceModelDefinition, HUGGINGFACE_BASE_URL, HUGGINGFACE_MODEL_CATALOG, -} from "../../src/agents/huggingface-models.js"; +} from "openclaw/plugin-sdk/provider-models"; import { applyAgentDefaultModelPrimary, applyProviderConfigWithModelCatalog, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; export const HUGGINGFACE_DEFAULT_MODEL_REF = "huggingface/deepseek-ai/DeepSeek-R1"; diff --git a/extensions/huggingface/provider-catalog.ts b/extensions/huggingface/provider-catalog.ts index 5dc87b751df..502a94f2a9e 100644 --- a/extensions/huggingface/provider-catalog.ts +++ b/extensions/huggingface/provider-catalog.ts @@ -1,10 +1,10 @@ import { buildHuggingfaceModelDefinition, discoverHuggingfaceModels, + type ModelProviderConfig, HUGGINGFACE_BASE_URL, HUGGINGFACE_MODEL_CATALOG, -} from "../../src/agents/huggingface-models.js"; -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +} from "openclaw/plugin-sdk/provider-models"; export async function buildHuggingfaceProvider( discoveryApiKey?: string, diff --git a/extensions/imessage/src/accounts.ts b/extensions/imessage/src/accounts.ts index 67ffb5e6865..5ee90339aa8 100644 --- a/extensions/imessage/src/accounts.ts +++ b/extensions/imessage/src/accounts.ts @@ -1,10 +1,10 @@ import { - type OpenClawConfig, createAccountListHelpers, normalizeAccountId, resolveAccountEntry, -} from "../../../src/plugin-sdk-internal/accounts.js"; -import type { IMessageAccountConfig } from "../../../src/plugin-sdk-internal/imessage.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/account-resolution"; +import type { IMessageAccountConfig } from "openclaw/plugin-sdk/imessage"; export type ResolvedIMessageAccount = { accountId: string; diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts index 5587914a0ce..0590eba9356 100644 --- a/extensions/imessage/src/channel.setup.ts +++ b/extensions/imessage/src/channel.setup.ts @@ -1,11 +1,94 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/imessage"; -import { type ResolvedIMessageAccount } from "./accounts.js"; +import { + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatTrimmedAllowFromEntries, + getChatChannelMeta, + IMessageConfigSchema, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, + setAccountEnabledInConfigSection, + type ChannelPlugin, +} from "openclaw/plugin-sdk/imessage"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, + type ResolvedIMessageAccount, +} from "./accounts.js"; +import { imessageSetupWizard } from "./plugin-shared.js"; import { imessageSetupAdapter } from "./setup-core.js"; -import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; -export const imessageSetupPlugin: ChannelPlugin = createIMessagePluginBase( - { - setupWizard: imessageSetupWizard, - setup: imessageSetupAdapter, +export const imessageSetupPlugin: ChannelPlugin = { + id: "imessage", + meta: { + ...getChatChannelMeta("imessage"), + aliases: ["imsg"], + showConfigured: false, }, -); + setupWizard: imessageSetupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + }, + reload: { configPrefixes: ["channels.imessage"] }, + configSchema: buildChannelConfigSchema(IMessageConfigSchema), + config: { + listAccountIds: (cfg) => listIMessageAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "imessage", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "imessage", + accountId, + clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "imessage", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + }), + collectWarnings: ({ account, cfg }) => + collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.imessage !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "iMessage groups", + openScope: "any member", + groupPolicyPath: "channels.imessage.groupPolicy", + groupAllowFromPath: "channels.imessage.groupAllowFrom", + mentionGated: false, + }), + }, + setup: imessageSetupAdapter, +}; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 95cac7d1123..5e3d48817a0 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,25 +1,43 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat"; +import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core"; import { + buildChannelConfigSchema, collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, formatTrimmedAllowFromEntries, + getChatChannelMeta, + IMessageConfigSchema, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, + setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk/imessage"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; -import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, + type ResolvedIMessageAccount, +} from "./accounts.js"; +import { imessageSetupWizard } from "./plugin-shared.js"; import { getIMessageRuntime } from "./runtime.js"; import { imessageSetupAdapter } from "./setup-core.js"; -import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; +const meta = getChatChannelMeta("imessage"); + type IMessageSendFn = ReturnType< typeof getIMessageRuntime >["channel"]["imessage"]["sendMessageIMessage"]; @@ -132,16 +150,55 @@ function resolveIMessageOutboundSessionRoute(params: { } export const imessagePlugin: ChannelPlugin = { - ...createIMessagePluginBase({ - setupWizard: imessageSetupWizard, - setup: imessageSetupAdapter, - }), + id: "imessage", + meta: { + ...meta, + aliases: ["imsg"], + showConfigured: false, + }, + setupWizard: imessageSetupWizard, pairing: { idLabel: "imessageSenderId", notifyApproval: async ({ id }) => { await getIMessageRuntime().channel.imessage.sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE); }, }, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + }, + reload: { configPrefixes: ["channels.imessage"] }, + configSchema: buildChannelConfigSchema(IMessageConfigSchema), + config: { + listAccountIds: (cfg) => listIMessageAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "imessage", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "imessage", + accountId, + clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), + }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => { @@ -162,6 +219,31 @@ export const imessagePlugin: ChannelPlugin = { }), }), }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + return buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "imessage", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + }); + }, + collectWarnings: ({ account, cfg }) => { + return collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.imessage !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "iMessage groups", + openScope: "any member", + groupPolicyPath: "channels.imessage.groupPolicy", + groupAllowFromPath: "channels.imessage.groupAllowFrom", + mentionGated: false, + }); + }, + }, groups: { resolveRequireMention: resolveIMessageGroupRequireMention, resolveToolPolicy: resolveIMessageGroupToolPolicy, @@ -174,6 +256,7 @@ export const imessagePlugin: ChannelPlugin = { hint: "", }, }, + setup: imessageSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit), diff --git a/extensions/imessage/src/client.ts b/extensions/imessage/src/client.ts index efe9e5deb3b..4c9dea59c2c 100644 --- a/extensions/imessage/src/client.ts +++ b/extensions/imessage/src/client.ts @@ -1,7 +1,7 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { createInterface, type Interface } from "node:readline"; -import type { RuntimeEnv } from "../../../src/runtime.js"; -import { resolveUserPath } from "../../../src/utils.js"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { resolveUserPath } from "openclaw/plugin-sdk/text-runtime"; import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; export type IMessageRpcError = { diff --git a/extensions/imessage/src/monitor/deliver.ts b/extensions/imessage/src/monitor/deliver.ts index e8db8c0cac9..65dc125be68 100644 --- a/extensions/imessage/src/monitor/deliver.ts +++ b/extensions/imessage/src/monitor/deliver.ts @@ -1,9 +1,9 @@ -import { chunkTextWithMode, resolveChunkMode } from "../../../../src/auto-reply/chunk.js"; -import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; -import { loadConfig } from "../../../../src/config/config.js"; -import { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js"; -import { convertMarkdownTables } from "../../../../src/markdown/tables.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; import type { createIMessageRpcClient } from "../client.js"; import { sendMessageIMessage } from "../send.js"; import type { SentMessageCache } from "./echo-cache.js"; diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index af900e21b40..531a8324dfd 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -1,34 +1,31 @@ -import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js"; +import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; +import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + resolveChannelGroupPolicy, + resolveChannelGroupRequireMention, +} from "openclaw/plugin-sdk/config-runtime"; +import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; import { formatInboundEnvelope, formatInboundFromLabel, resolveEnvelopeFormatOptions, type EnvelopeFormatOptions, -} from "../../../../src/auto-reply/envelope.js"; +} from "openclaw/plugin-sdk/reply-runtime"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntryIfEnabled, type HistoryEntry, -} from "../../../../src/auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; -import { - buildMentionRegexes, - matchesMentionPatterns, -} from "../../../../src/auto-reply/reply/mentions.js"; -import { resolveDualTextControlCommandGate } from "../../../../src/channels/command-gating.js"; -import { logInboundDrop } from "../../../../src/channels/logging.js"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import { - resolveChannelGroupPolicy, - resolveChannelGroupRequireMention, -} from "../../../../src/config/group-policy.js"; -import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +} from "openclaw/plugin-sdk/reply-runtime"; +import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; +import { buildMentionRegexes, matchesMentionPatterns } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { DM_GROUP_ACCESS_REASON, resolveDmGroupAccessWithLists, -} from "../../../../src/security/dm-policy-shared.js"; -import { sanitizeTerminalText } from "../../../../src/terminal/safe-text.js"; -import { truncateUtf16Safe } from "../../../../src/utils.js"; +} from "openclaw/plugin-sdk/security-runtime"; +import { sanitizeTerminalText } from "openclaw/plugin-sdk/text-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime"; import { formatIMessageChatTarget, isAllowedIMessageSender, diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index e3c062cd814..dc15715d652 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -1,42 +1,42 @@ import fs from "node:fs/promises"; -import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; -import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; -import { dispatchInboundMessage } from "../../../../src/auto-reply/dispatch.js"; -import { - clearHistoryEntriesIfEnabled, - DEFAULT_GROUP_HISTORY_LIMIT, - type HistoryEntry, -} from "../../../../src/auto-reply/reply/history.js"; -import { createReplyDispatcher } from "../../../../src/auto-reply/reply/reply-dispatcher.js"; +import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "../../../../src/channels/inbound-debounce-policy.js"; -import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; -import { recordInboundSession } from "../../../../src/channels/session.js"; -import { loadConfig } from "../../../../src/config/config.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, -} from "../../../../src/config/runtime-group-policy.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js"; -import { normalizeScpRemoteHost } from "../../../../src/infra/scp-host.js"; -import { waitForTransportReady } from "../../../../src/infra/transport-ready.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { issuePairingChallenge } from "openclaw/plugin-sdk/conversation-runtime"; +import { + readChannelAllowFromStore, + upsertChannelPairingRequest, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/infra-runtime"; +import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime"; import { isInboundPathAllowed, resolveIMessageAttachmentRoots, resolveIMessageRemoteAttachmentRoots, -} from "../../../../src/media/inbound-path-policy.js"; -import { kindFromMime } from "../../../../src/media/mime.js"; -import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; +} from "openclaw/plugin-sdk/media-runtime"; +import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; +import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { - readChannelAllowFromStore, - upsertChannelPairingRequest, -} from "../../../../src/pairing/pairing-store.js"; -import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../src/security/dm-policy-shared.js"; -import { truncateUtf16Safe } from "../../../../src/utils.js"; + clearHistoryEntriesIfEnabled, + DEFAULT_GROUP_HISTORY_LIMIT, + type HistoryEntry, +} from "openclaw/plugin-sdk/reply-runtime"; +import { createReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime"; +import { danger, logVerbose, shouldLogVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime"; import { resolveIMessageAccount } from "../accounts.js"; import { createIMessageRpcClient } from "../client.js"; import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js"; diff --git a/extensions/imessage/src/monitor/reflection-guard.ts b/extensions/imessage/src/monitor/reflection-guard.ts index 0af95d957cc..9ed38d2a175 100644 --- a/extensions/imessage/src/monitor/reflection-guard.ts +++ b/extensions/imessage/src/monitor/reflection-guard.ts @@ -4,7 +4,7 @@ * bounced back as a new inbound message — creating an echo loop. */ -import { findCodeRegions, isInsideCode } from "../../../../src/shared/text/code-regions.js"; +import { findCodeRegions, isInsideCode } from "openclaw/plugin-sdk/text-runtime"; const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/; const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/i; diff --git a/extensions/imessage/src/monitor/runtime.ts b/extensions/imessage/src/monitor/runtime.ts index e4fe6ae4336..437224013d4 100644 --- a/extensions/imessage/src/monitor/runtime.ts +++ b/extensions/imessage/src/monitor/runtime.ts @@ -1,5 +1,5 @@ -import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; -import { normalizeStringEntries } from "../../../../src/shared/string-normalization.js"; +import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import type { MonitorIMessageOpts } from "./types.js"; export function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv { diff --git a/extensions/imessage/src/monitor/sanitize-outbound.ts b/extensions/imessage/src/monitor/sanitize-outbound.ts index 83eb75a8da2..533eb7f2176 100644 --- a/extensions/imessage/src/monitor/sanitize-outbound.ts +++ b/extensions/imessage/src/monitor/sanitize-outbound.ts @@ -1,4 +1,4 @@ -import { stripAssistantInternalScaffolding } from "../../../../src/shared/text/assistant-visible-text.js"; +import { stripAssistantInternalScaffolding } from "openclaw/plugin-sdk/text-runtime"; /** * Patterns that indicate assistant-internal metadata leaked into text. diff --git a/extensions/imessage/src/monitor/types.ts b/extensions/imessage/src/monitor/types.ts index 074c7c34c9f..a03ed5faea8 100644 --- a/extensions/imessage/src/monitor/types.ts +++ b/extensions/imessage/src/monitor/types.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; export type IMessageAttachment = { original_path?: string | null; diff --git a/extensions/imessage/src/outbound-adapter.ts b/extensions/imessage/src/outbound-adapter.ts index ae5e7c2836a..cd961c30bfa 100644 --- a/extensions/imessage/src/outbound-adapter.ts +++ b/extensions/imessage/src/outbound-adapter.ts @@ -1,11 +1,8 @@ import { createScopedChannelMediaMaxBytesResolver, createDirectTextMediaOutbound, -} from "../../../src/channels/plugins/outbound/direct-text-media.js"; -import { - resolveOutboundSendDep, - type OutboundSendDeps, -} from "../../../src/infra/outbound/send-deps.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; import { sendMessageIMessage } from "./send.js"; function resolveIMessageSender(deps: OutboundSendDeps | undefined) { diff --git a/extensions/imessage/src/plugin-shared.ts b/extensions/imessage/src/plugin-shared.ts index c7ed39cd21a..415a152f56a 100644 --- a/extensions/imessage/src/plugin-shared.ts +++ b/extensions/imessage/src/plugin-shared.ts @@ -1,4 +1,4 @@ -import { type ChannelPlugin } from "../../../src/plugin-sdk-internal/imessage.js"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/imessage"; import { type ResolvedIMessageAccount } from "./accounts.js"; import { createIMessageSetupWizardProxy } from "./setup-core.js"; diff --git a/extensions/imessage/src/probe.ts b/extensions/imessage/src/probe.ts index 1b6ab665d09..7ae049f02eb 100644 --- a/extensions/imessage/src/probe.ts +++ b/extensions/imessage/src/probe.ts @@ -1,8 +1,8 @@ -import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; -import { detectBinary } from "../../../src/commands/onboard-helpers.js"; -import { loadConfig } from "../../../src/config/config.js"; -import { runCommandWithTimeout } from "../../../src/process/exec.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { detectBinary } from "openclaw/plugin-sdk/setup"; import { createIMessageRpcClient } from "./client.js"; import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts index 3a49020348f..a7ed927b9ab 100644 --- a/extensions/imessage/src/runtime.ts +++ b/extensions/imessage/src/runtime.ts @@ -1,7 +1,5 @@ -import { - createPluginRuntimeStore, - type PluginRuntime, -} from "../../../src/plugin-sdk-internal/core.js"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } = createPluginRuntimeStore("iMessage runtime not initialized"); diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index 5bc02b6bb7f..70c996329e1 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -1,8 +1,8 @@ -import { loadConfig } from "../../../src/config/config.js"; -import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; -import { convertMarkdownTables } from "../../../src/markdown/tables.js"; -import { kindFromMime } from "../../../src/media/mime.js"; -import { resolveOutboundAttachmentFromUrl } from "../../../src/media/outbound-attachment.js"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; +import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime"; +import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js"; diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 45f385e0691..eed33e64192 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,18 +1,21 @@ -import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import { + applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + migrateBaseNameToDefaultAccount, + normalizeAccountId, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, type OpenClawConfig, type WizardPrompter, -} from "../../../src/plugin-sdk-internal/setup.js"; +} from "openclaw/plugin-sdk/setup"; import type { ChannelSetupAdapter, ChannelSetupDmPolicy, ChannelSetupWizard, -} from "../../../src/plugin-sdk-internal/setup.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +} from "openclaw/plugin-sdk/setup"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, @@ -95,23 +98,66 @@ async function promptIMessageAllowFrom(params: { }); } -export const imessageSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ - channelKey: channel, - buildPatch: (input) => buildIMessageSetupPatch(input), -}); - -type IMessageSetupWizardHandlers = { - resolveStatusLines: NonNullable["resolveStatusLines"]; - resolveSelectionHint: NonNullable["resolveSelectionHint"]; - resolveQuickstartScore: NonNullable["resolveQuickstartScore"]; - shouldPromptCliPath: NonNullable< - NonNullable[number]["shouldPrompt"] - >; +export const imessageSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + imessage: { + ...next.channels?.imessage, + enabled: true, + ...buildIMessageSetupPatch(input), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + imessage: { + ...next.channels?.imessage, + enabled: true, + accounts: { + ...next.channels?.imessage?.accounts, + [accountId]: { + ...next.channels?.imessage?.accounts?.[accountId], + enabled: true, + ...buildIMessageSetupPatch(input), + }, + }, + }, + }, + }; + }, }; -export function createIMessageSetupWizardBase( - handlers: IMessageSetupWizardHandlers, -): ChannelSetupWizard { +export function createIMessageSetupWizardProxy( + loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>, +) { const imessageDmPolicy: ChannelSetupDmPolicy = { label: "iMessage", channel, @@ -147,9 +193,12 @@ export function createIMessageSetupWizardBase( account.config.region, ); }), - resolveStatusLines: handlers.resolveStatusLines, - resolveSelectionHint: handlers.resolveSelectionHint, - resolveQuickstartScore: handlers.resolveQuickstartScore, + resolveStatusLines: async (params) => + (await loadWizard()).imessageSetupWizard.status.resolveStatusLines?.(params) ?? [], + resolveSelectionHint: async (params) => + await (await loadWizard()).imessageSetupWizard.status.resolveSelectionHint?.(params), + resolveQuickstartScore: async (params) => + await (await loadWizard()).imessageSetupWizard.status.resolveQuickstartScore?.(params), }, credentials: [], textInputs: [ @@ -160,7 +209,12 @@ export function createIMessageSetupWizardBase( resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", currentValue: ({ cfg, accountId }) => resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", - shouldPrompt: handlers.shouldPromptCliPath, + shouldPrompt: async (params) => { + const input = (await loadWizard()).imessageSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }, confirmCurrentValue: false, applyCurrentValue: true, helpTitle: "iMessage", @@ -181,22 +235,3 @@ export function createIMessageSetupWizardBase( disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } - -export function createIMessageSetupWizardProxy( - loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>, -) { - return createIMessageSetupWizardBase({ - resolveStatusLines: async (params) => - (await loadWizard()).imessageSetupWizard.status.resolveStatusLines?.(params) ?? [], - resolveSelectionHint: async (params) => - await (await loadWizard()).imessageSetupWizard.status.resolveSelectionHint?.(params), - resolveQuickstartScore: async (params) => - await (await loadWizard()).imessageSetupWizard.status.resolveQuickstartScore?.(params), - shouldPromptCliPath: async (params) => { - const input = (await loadWizard()).imessageSetupWizard.textInputs?.find( - (entry) => entry.inputKey === "cliPath", - ); - return (await input?.shouldPrompt?.(params)) ?? false; - }, - }); -} diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index c1158960cec..48c9f130355 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,23 +1,134 @@ -import { detectBinary } from "../../../src/plugin-sdk-internal/setup.js"; -import { createIMessageSetupWizardBase, imessageSetupAdapter } from "./setup-core.js"; +import { + DEFAULT_ACCOUNT_ID, + detectBinary, + formatDocsLink, + type OpenClawConfig, + parseSetupEntriesAllowingWildcard, + promptParsedAllowFromForScopedChannel, + setChannelDmPolicyWithAllowFrom, + setSetupChannelEnabled, + type WizardPrompter, +} from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, +} from "./accounts.js"; +import { imessageSetupAdapter, parseIMessageAllowFromEntries } from "./setup-core.js"; -export const imessageSetupWizard = createIMessageSetupWizardBase({ - resolveStatusLines: async ({ cfg, configured }) => { - const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - const cliDetected = await detectBinary(cliPath); - return [ - `iMessage: ${configured ? "configured" : "needs setup"}`, - `imsg: ${cliDetected ? "found" : "missing"} (${cliPath})`, - ]; +const channel = "imessage" as const; + +async function promptIMessageAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + return promptParsedAllowFromForScopedChannel({ + cfg: params.cfg, + channel, + accountId: params.accountId, + defaultAccountId: resolveDefaultIMessageAccountId(params.cfg), + prompter: params.prompter, + noteTitle: "iMessage allowlist", + noteLines: [ + "Allowlist iMessage DMs by handle or chat target.", + "Examples:", + "- +15555550123", + "- user@example.com", + "- chat_id:123", + "- chat_guid:... or chat_identifier:...", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/imessage", "imessage")}`, + ], + message: "iMessage allowFrom (handle or chat_id)", + placeholder: "+15555550123, user@example.com, chat_id:123", + parseEntries: parseIMessageAllowFromEntries, + getExistingAllowFrom: ({ cfg, accountId }) => + resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? [], + }); +} + +const imessageDmPolicy: ChannelSetupDmPolicy = { + label: "iMessage", + channel, + policyKey: "channels.imessage.dmPolicy", + allowFromKey: "channels.imessage.allowFrom", + getCurrent: (cfg) => cfg.channels?.imessage?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptIMessageAllowFrom, +}; + +export const imessageSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "imsg found", + unconfiguredHint: "imsg missing", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listIMessageAccountIds(cfg).some((accountId) => { + const account = resolveIMessageAccount({ cfg, accountId }); + return Boolean( + account.config.cliPath || + account.config.dbPath || + account.config.allowFrom || + account.config.service || + account.config.region, + ); + }), + resolveStatusLines: async ({ cfg, configured }) => { + const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; + const cliDetected = await detectBinary(cliPath); + return [ + `iMessage: ${configured ? "configured" : "needs setup"}`, + `imsg: ${cliDetected ? "found" : "missing"} (${cliPath})`, + ]; + }, + resolveSelectionHint: async ({ cfg }) => { + const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; + return (await detectBinary(cliPath)) ? "imsg found" : "imsg missing"; + }, + resolveQuickstartScore: async ({ cfg }) => { + const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; + return (await detectBinary(cliPath)) ? 1 : 0; + }, }, - resolveSelectionHint: async ({ cfg }) => { - const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - return (await detectBinary(cliPath)) ? "imsg found" : "imsg missing"; + credentials: [], + textInputs: [ + { + inputKey: "cliPath", + message: "imsg CLI path", + initialValue: ({ cfg, accountId }) => + resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", + currentValue: ({ cfg, accountId }) => + resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", + shouldPrompt: async ({ currentValue }) => !(await detectBinary(currentValue ?? "imsg")), + confirmCurrentValue: false, + applyCurrentValue: true, + helpTitle: "iMessage", + helpLines: ["imsg CLI path required to enable iMessage."], + }, + ], + completionNote: { + title: "iMessage next steps", + lines: [ + "This is still a work in progress.", + "Ensure OpenClaw has Full Disk Access to Messages DB.", + "Grant Automation permission for Messages when prompted.", + "List chats with: imsg chats --limit 20", + `Docs: ${formatDocsLink("/imessage", "imessage")}`, + ], }, - resolveQuickstartScore: async ({ cfg }) => { - const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - return (await detectBinary(cliPath)) ? 1 : 0; - }, - shouldPromptCliPath: async ({ currentValue }) => !(await detectBinary(currentValue ?? "imsg")), -}); -export { imessageSetupAdapter, parseIMessageAllowFromEntries } from "./setup-core.js"; + dmPolicy: imessageDmPolicy, + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), +}; + +export { imessageSetupAdapter, parseIMessageAllowFromEntries }; diff --git a/extensions/imessage/src/target-parsing-helpers.ts b/extensions/imessage/src/target-parsing-helpers.ts index 7995b271fe4..04881fa2131 100644 --- a/extensions/imessage/src/target-parsing-helpers.ts +++ b/extensions/imessage/src/target-parsing-helpers.ts @@ -1,4 +1,4 @@ -import { isAllowedParsedChatSender } from "../../../src/plugin-sdk-internal/imessage.js"; +import { isAllowedParsedChatSender } from "openclaw/plugin-sdk/allow-from"; export type ServicePrefix = { prefix: string; service: TService }; diff --git a/extensions/imessage/src/targets.ts b/extensions/imessage/src/targets.ts index a376a6e7f45..d6cd6a11f38 100644 --- a/extensions/imessage/src/targets.ts +++ b/extensions/imessage/src/targets.ts @@ -1,4 +1,4 @@ -import { normalizeE164 } from "../../../src/utils.js"; +import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { createAllowedChatSenderMatcher, type ChatSenderAllowParams, diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index 3c28017e1e9..23422e30ba0 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -1,15 +1,15 @@ +import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime"; +import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { + applyAccountNameToChannelSection, patchScopedAccountConfig, - prepareScopedSetupConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; +} from "openclaw/plugin-sdk/setup"; import { setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; -import type { DmPolicy } from "../../../src/config/types.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; +} from "openclaw/plugin-sdk/setup"; import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; const channel = "irc" as const; @@ -100,7 +100,7 @@ export function setIrcGroupAccess( export const ircSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - prepareScopedSetupConfig({ + applyAccountNameToChannelSection({ cfg, channelKey: channel, accountId, @@ -118,7 +118,7 @@ export const ircSetupAdapter: ChannelSetupAdapter = { }, applyAccountConfig: ({ cfg, accountId, input }) => { const setupInput = input as IrcSetupInput; - const namedConfig = prepareScopedSetupConfig({ + const namedConfig = applyAccountNameToChannelSection({ cfg, channelKey: channel, accountId, diff --git a/extensions/irc/src/setup-surface.ts b/extensions/irc/src/setup-surface.ts index 1607a9bdd54..cdadcffbaec 100644 --- a/extensions/irc/src/setup-surface.ts +++ b/extensions/irc/src/setup-surface.ts @@ -1,13 +1,10 @@ -import { - resolveSetupAccountId, - 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 { DmPolicy } from "../../../src/config/types.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"; +import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; +import { resolveSetupAccountId, setSetupChannelEnabled } from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup"; +import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js"; import { isChannelTarget, diff --git a/extensions/kilocode/index.ts b/extensions/kilocode/index.ts index 7089d212628..33dc9718021 100644 --- a/extensions/kilocode/index.ts +++ b/extensions/kilocode/index.ts @@ -1,9 +1,9 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { createKilocodeWrapper, isProxyReasoningUnsupported, -} from "../../src/agents/pi-embedded-runner/proxy-stream-wrappers.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +} from "openclaw/plugin-sdk/provider-stream"; import { applyKilocodeConfig, KILOCODE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildKilocodeProviderWithDiscovery } from "./provider-catalog.js"; diff --git a/extensions/kilocode/onboard.ts b/extensions/kilocode/onboard.ts index 260233c3d34..fd285341f52 100644 --- a/extensions/kilocode/onboard.ts +++ b/extensions/kilocode/onboard.ts @@ -1,12 +1,9 @@ +import { KILOCODE_BASE_URL, KILOCODE_DEFAULT_MODEL_REF } from "openclaw/plugin-sdk/provider-models"; import { applyAgentDefaultModelPrimary, applyProviderConfigWithModelCatalog, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; -import { - KILOCODE_BASE_URL, - KILOCODE_DEFAULT_MODEL_REF, -} from "../../src/providers/kilocode-shared.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; import { buildKilocodeProvider } from "./provider-catalog.js"; export { KILOCODE_BASE_URL, KILOCODE_DEFAULT_MODEL_REF }; diff --git a/extensions/kilocode/provider-catalog.ts b/extensions/kilocode/provider-catalog.ts index 696b351c530..98e324f4784 100644 --- a/extensions/kilocode/provider-catalog.ts +++ b/extensions/kilocode/provider-catalog.ts @@ -1,12 +1,12 @@ -import { discoverKilocodeModels } from "../../src/agents/kilocode-models.js"; -import type { ModelProviderConfig } from "../../src/config/types.models.js"; import { + discoverKilocodeModels, + type ModelProviderConfig, KILOCODE_BASE_URL, KILOCODE_DEFAULT_CONTEXT_WINDOW, KILOCODE_DEFAULT_COST, KILOCODE_DEFAULT_MAX_TOKENS, KILOCODE_MODEL_CATALOG, -} from "../../src/providers/kilocode-shared.js"; +} from "openclaw/plugin-sdk/provider-models"; export function buildKilocodeProvider(): ModelProviderConfig { return { diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index 709e5a8de4c..3803a0af951 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -1,69 +1,47 @@ -import { - emptyPluginConfigSchema, - type OpenClawPluginApi, - type ProviderRuntimeModel, -} from "openclaw/plugin-sdk/core"; -import { findNormalizedProviderValue, normalizeProviderId } from "../../src/agents/provider-id.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; -import { isRecord } from "../../src/utils.js"; -import { applyKimiCodeConfig, KIMI_DEFAULT_MODEL_REF } from "./onboard.js"; -import { - buildKimiProvider, - KIMI_DEFAULT_MODEL_ID, - KIMI_LEGACY_MODEL_ID, - KIMI_UPSTREAM_MODEL_ID, -} from "./provider-catalog.js"; +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { isRecord } from "openclaw/plugin-sdk/text-runtime"; +import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "./onboard.js"; +import { buildKimiCodingProvider } from "./provider-catalog.js"; -const PROVIDER_ID = "kimi"; -const KIMI_TRANSPORT_MODEL_IDS = new Set([KIMI_DEFAULT_MODEL_ID, KIMI_LEGACY_MODEL_ID]); - -function normalizeKimiTransportModel(model: ProviderRuntimeModel): ProviderRuntimeModel { - if (!KIMI_TRANSPORT_MODEL_IDS.has(model.id)) { - return model; - } - return { - ...model, - id: KIMI_UPSTREAM_MODEL_ID, - name: "Kimi Code", - }; -} +const PROVIDER_ID = "kimi-coding"; const kimiCodingPlugin = { id: PROVIDER_ID, - name: "Kimi Code Provider", - description: "Bundled Kimi Code provider plugin", + name: "Kimi Provider", + description: "Bundled Kimi provider plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerProvider({ id: PROVIDER_ID, - label: "Kimi Code", - aliases: ["kimi-code", "kimi-coding"], + label: "Kimi", + aliases: ["kimi", "kimi-code"], docsPath: "/providers/moonshot", envVars: ["KIMI_API_KEY", "KIMICODE_API_KEY"], auth: [ createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key", - label: "Kimi Code API key", - hint: "Dedicated coding endpoint", + label: "Kimi API key (subscription)", + hint: "Kimi K2.5 + Kimi", optionKey: "kimiCodeApiKey", flagName: "--kimi-code-api-key", envVar: "KIMI_API_KEY", - promptMessage: "Enter Kimi Code API key", - defaultModel: KIMI_DEFAULT_MODEL_REF, + promptMessage: "Enter Kimi API key", + defaultModel: KIMI_CODING_MODEL_REF, expectedProviders: ["kimi", "kimi-code", "kimi-coding"], applyConfig: (cfg) => applyKimiCodeConfig(cfg), noteMessage: [ - "Kimi Code uses a dedicated coding endpoint and API key.", + "Kimi uses a dedicated coding endpoint and API key.", "Get your API key at: https://www.kimi.com/code/en", ].join("\n"), - noteTitle: "Kimi Code", + noteTitle: "Kimi", wizard: { choiceId: "kimi-code-api-key", - choiceLabel: "Kimi Code API key", - groupId: "kimi-code", - groupLabel: "Kimi Code", - groupHint: "Dedicated coding endpoint", + choiceLabel: "Kimi API key (subscription)", + groupId: "moonshot", + groupLabel: "Moonshot AI (Kimi K2.5)", + groupHint: "Kimi K2.5 + Kimi", }, }), ], @@ -74,11 +52,8 @@ const kimiCodingPlugin = { if (!apiKey) { return null; } - const explicitProvider = findNormalizedProviderValue( - ctx.config.models?.providers, - PROVIDER_ID, - ); - const builtInProvider = buildKimiProvider(); + const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; + const builtInProvider = buildKimiCodingProvider(); const explicitBaseUrl = typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; const explicitHeaders = isRecord(explicitProvider?.headers) @@ -104,12 +79,6 @@ const kimiCodingPlugin = { capabilities: { preserveAnthropicThinkingSignatures: false, }, - normalizeResolvedModel: (ctx) => { - if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { - return undefined; - } - return normalizeKimiTransportModel(ctx.model); - }, }); }, }; diff --git a/extensions/kimi-coding/onboard.ts b/extensions/kimi-coding/onboard.ts index 07feea91327..c97738f1e72 100644 --- a/extensions/kimi-coding/onboard.ts +++ b/extensions/kimi-coding/onboard.ts @@ -1,44 +1,38 @@ import { applyAgentDefaultModelPrimary, - applyProviderConfigWithModelCatalog, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; + applyProviderConfigWithDefaultModel, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; import { buildKimiCodingProvider, - KIMI_BASE_URL, - KIMI_DEFAULT_MODEL_ID, - KIMI_LEGACY_MODEL_ID, + KIMI_CODING_BASE_URL, + KIMI_CODING_DEFAULT_MODEL_ID, } from "./provider-catalog.js"; -export const KIMI_DEFAULT_MODEL_REF = `kimi/${KIMI_DEFAULT_MODEL_ID}`; -export const KIMI_LEGACY_MODEL_REF = `kimi/${KIMI_LEGACY_MODEL_ID}`; -export const KIMI_CODING_MODEL_REF = KIMI_DEFAULT_MODEL_REF; +export const KIMI_CODING_MODEL_REF = `kimi-coding/${KIMI_CODING_DEFAULT_MODEL_ID}`; export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; - models[KIMI_DEFAULT_MODEL_REF] = { - ...models[KIMI_DEFAULT_MODEL_REF], - alias: models[KIMI_DEFAULT_MODEL_REF]?.alias ?? "Kimi Code", - }; - models[KIMI_LEGACY_MODEL_REF] = { - ...models[KIMI_LEGACY_MODEL_REF], - alias: models[KIMI_LEGACY_MODEL_REF]?.alias ?? "Kimi Code", + models[KIMI_CODING_MODEL_REF] = { + ...models[KIMI_CODING_MODEL_REF], + alias: models[KIMI_CODING_MODEL_REF]?.alias ?? "Kimi", }; - const catalog = buildKimiCodingProvider().models ?? []; - if (catalog.length === 0) { + const defaultModel = buildKimiCodingProvider().models[0]; + if (!defaultModel) { return cfg; } - return applyProviderConfigWithModelCatalog(cfg, { + return applyProviderConfigWithDefaultModel(cfg, { agentModels: models, - providerId: "kimi", + providerId: "kimi-coding", api: "anthropic-messages", - baseUrl: KIMI_BASE_URL, - catalogModels: catalog, + baseUrl: KIMI_CODING_BASE_URL, + defaultModel, + defaultModelId: KIMI_CODING_DEFAULT_MODEL_ID, }); } export function applyKimiCodeConfig(cfg: OpenClawConfig): OpenClawConfig { - return applyAgentDefaultModelPrimary(applyKimiCodeProviderConfig(cfg), KIMI_DEFAULT_MODEL_REF); + return applyAgentDefaultModelPrimary(applyKimiCodeProviderConfig(cfg), KIMI_CODING_MODEL_REF); } diff --git a/extensions/kimi-coding/provider-catalog.ts b/extensions/kimi-coding/provider-catalog.ts index 439c86fdff0..5fc27495c76 100644 --- a/extensions/kimi-coding/provider-catalog.ts +++ b/extensions/kimi-coding/provider-catalog.ts @@ -1,4 +1,4 @@ -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-models"; export const KIMI_BASE_URL = "https://api.kimi.com/coding/"; const KIMI_CODING_USER_AGENT = "claude-code/0.1.0"; diff --git a/extensions/line/src/channel.setup.ts b/extensions/line/src/channel.setup.ts index 71a1d87c45d..771107dff58 100644 --- a/extensions/line/src/channel.setup.ts +++ b/extensions/line/src/channel.setup.ts @@ -9,7 +9,7 @@ import { listLineAccountIds, resolveDefaultLineAccountId, resolveLineAccount, -} from "../../../src/line/accounts.js"; +} from "openclaw/plugin-sdk/line"; import { lineSetupAdapter } from "./setup-core.js"; import { lineSetupWizard } from "./setup-surface.js"; diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 67c9c674df5..737ba1cc856 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -1,12 +1,11 @@ -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; import { listLineAccountIds, normalizeAccountId, resolveLineAccount, -} from "../../../src/line/accounts.js"; -import type { LineConfig } from "../../../src/line/types.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; + type LineConfig, +} from "openclaw/plugin-sdk/line"; +import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; const channel = "line" as const; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 9ea7dd4ce68..d548b096434 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -1,13 +1,13 @@ +import { resolveLineAccount } from "openclaw/plugin-sdk/line"; import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, setSetupChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, 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 { resolveLineAccount } from "../../../src/line/accounts.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; + type ChannelSetupDmPolicy, + type ChannelSetupWizard, +} from "openclaw/plugin-sdk/setup"; import { isLineConfigured, listLineAccountIds, diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index 072ab2fb8c1..09374b7746e 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -1,5 +1,5 @@ +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/matrix"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; import { getMatrixRuntime } from "./runtime.js"; diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 45bfbc5ac82..781967c70a6 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -1,10 +1,13 @@ +import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, + migrateBaseNameToDefaultAccount, + normalizeAccountId, type OpenClawConfig, } from "openclaw/plugin-sdk/mattermost"; -import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; @@ -24,8 +27,15 @@ export function resolveMattermostAccountWithSecrets(cfg: OpenClawConfig, account }); } -export const mattermostSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ - channelKey: channel, +export const mattermostSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), validateInput: ({ accountId, input }) => { const token = input.botToken ?? input.token; const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); @@ -40,14 +50,32 @@ export const mattermostSetupAdapter: ChannelSetupAdapter = createPatchedAccountS } return null; }, - buildPatch: (input) => { + applyAccountConfig: ({ cfg, accountId, input }) => { const token = input.botToken ?? input.token; const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); - return input.useEnv - ? {} - : { - ...(token ? { botToken: token } : {}), - ...(baseUrl ? { baseUrl } : {}), - }; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: input.useEnv + ? {} + : { + ...(token ? { botToken: token } : {}), + ...(baseUrl ? { baseUrl } : {}), + }, + }); }, -}); +}; diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index 13b69542d02..d3b0a66b4c8 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -4,8 +4,8 @@ import { hasConfiguredSecretInput, type OpenClawConfig, } from "openclaw/plugin-sdk/mattermost"; -import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup"; import { listMattermostAccountIds } from "./mattermost/accounts.js"; import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { diff --git a/extensions/microsoft/index.ts b/extensions/microsoft/index.ts index 358ea2057a0..db0bebbcc0b 100644 --- a/extensions/microsoft/index.ts +++ b/extensions/microsoft/index.ts @@ -1,5 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildMicrosoftSpeechProvider } from "../../src/tts/providers/microsoft.js"; +import { buildMicrosoftSpeechProvider } from "openclaw/plugin-sdk/speech"; const microsoftPlugin = { id: "microsoft", diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 8dbe47f466c..30894be556d 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -6,14 +6,13 @@ import { type ProviderAuthResult, type ProviderCatalogContext, } 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 { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { - minimaxMediaUnderstandingProvider, - minimaxPortalMediaUnderstandingProvider, -} from "./media-understanding-provider.js"; + MINIMAX_OAUTH_MARKER, + createProviderApiKeyAuthMethod, + ensureAuthProfileStore, + listProfilesForProvider, +} from "openclaw/plugin-sdk/provider-auth"; +import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage"; import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; import { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js"; import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js"; @@ -274,8 +273,6 @@ const minimaxPlugin = { ], isModernModelRef: ({ modelId }) => isModernMiniMaxModel(modelId), }); - api.registerMediaUnderstandingProvider(minimaxMediaUnderstandingProvider); - api.registerMediaUnderstandingProvider(minimaxPortalMediaUnderstandingProvider); }, }; diff --git a/extensions/minimax/onboard.ts b/extensions/minimax/onboard.ts index 6a2ff47e1f0..2edcf9637e4 100644 --- a/extensions/minimax/onboard.ts +++ b/extensions/minimax/onboard.ts @@ -1,14 +1,14 @@ -import { - applyAgentDefaultModelPrimary, - applyOnboardAuthAgentModelsAndProviders, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; -import type { ModelProviderConfig } from "../../src/config/types.models.js"; import { buildMinimaxApiModelDefinition, MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL, -} from "./model-definitions.js"; +} from "openclaw/plugin-sdk/provider-models"; +import { + applyAgentDefaultModelPrimary, + applyOnboardAuthAgentModelsAndProviders, + type ModelProviderConfig, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; type MinimaxApiProviderConfigParams = { providerId: string; diff --git a/extensions/minimax/provider-catalog.ts b/extensions/minimax/provider-catalog.ts index 83c1c46df13..ab8cceb9c53 100644 --- a/extensions/minimax/provider-catalog.ts +++ b/extensions/minimax/provider-catalog.ts @@ -1,4 +1,7 @@ -import type { ModelDefinitionConfig, ModelProviderConfig } from "../../src/config/types.models.js"; +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-models"; const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic"; export const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5"; diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index 6da8e431759..72b3b6a60ac 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -1,6 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; -import { mistralMediaUnderstandingProvider } from "./media-understanding-provider.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "mistral"; @@ -51,7 +50,6 @@ const mistralPlugin = { ], }, }); - api.registerMediaUnderstandingProvider(mistralMediaUnderstandingProvider); }, }; diff --git a/extensions/mistral/onboard.ts b/extensions/mistral/onboard.ts index 9a60e3f7c72..cefdeda2d01 100644 --- a/extensions/mistral/onboard.ts +++ b/extensions/mistral/onboard.ts @@ -1,16 +1,15 @@ -import { - applyAgentDefaultModelPrimary, - applyProviderConfigWithDefaultModel, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; import { buildMistralModelDefinition, MISTRAL_BASE_URL, MISTRAL_DEFAULT_MODEL_ID, - MISTRAL_DEFAULT_MODEL_REF, -} from "./model-definitions.js"; +} from "openclaw/plugin-sdk/provider-models"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModel, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; -export { MISTRAL_DEFAULT_MODEL_REF }; +export const MISTRAL_DEFAULT_MODEL_REF = `mistral/${MISTRAL_DEFAULT_MODEL_ID}`; export function applyMistralProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; diff --git a/extensions/modelstudio/index.ts b/extensions/modelstudio/index.ts index e4dc27ee6df..ad5c1852b59 100644 --- a/extensions/modelstudio/index.ts +++ b/extensions/modelstudio/index.ts @@ -1,6 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; -import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyModelStudioConfig, applyModelStudioConfigCn, @@ -79,13 +78,22 @@ const modelStudioPlugin = { ], catalog: { order: "simple", - run: (ctx) => - buildSingleProviderApiKeyCatalog({ - ctx, - providerId: PROVIDER_ID, - buildProvider: buildModelStudioProvider, - allowExplicitBaseUrl: true, - }), + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; + const explicitBaseUrl = + typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; + return { + provider: { + ...buildModelStudioProvider(), + ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), + apiKey, + }, + }; + }, }, }); }, diff --git a/extensions/modelstudio/onboard.ts b/extensions/modelstudio/onboard.ts index 9a8760b8550..881b742dde4 100644 --- a/extensions/modelstudio/onboard.ts +++ b/extensions/modelstudio/onboard.ts @@ -1,13 +1,13 @@ -import { - applyAgentDefaultModelPrimary, - applyProviderConfigWithModelCatalog, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; import { MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL, -} from "./model-definitions.js"; +} from "openclaw/plugin-sdk/provider-models"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; import { buildModelStudioProvider } from "./provider-catalog.js"; export { MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL }; diff --git a/extensions/modelstudio/provider-catalog.ts b/extensions/modelstudio/provider-catalog.ts index ea9f2b2ae72..0908155a5f8 100644 --- a/extensions/modelstudio/provider-catalog.ts +++ b/extensions/modelstudio/provider-catalog.ts @@ -1,4 +1,7 @@ -import type { ModelDefinitionConfig, ModelProviderConfig } from "../../src/config/types.models.js"; +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-models"; export const MODELSTUDIO_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus"; diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 5ef777edcc4..e8d7ecedb0c 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -1,17 +1,14 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { createMoonshotThinkingWrapper, resolveMoonshotThinkingType, -} from "../../src/agents/pi-embedded-runner/moonshot-stream-wrappers.js"; +} from "openclaw/plugin-sdk/provider-stream"; import { createPluginBackedWebSearchProvider, getScopedCredentialValue, setScopedCredentialValue, -} from "../../src/agents/tools/web-search-plugin-factory.js"; -import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; -import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; -import type { OpenClawPluginApi } from "../../src/plugins/types.js"; -import { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js"; +} from "openclaw/plugin-sdk/provider-web-search"; import { applyMoonshotConfig, applyMoonshotConfigCn, @@ -36,8 +33,8 @@ const moonshotPlugin = { createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key", - label: "Moonshot API key (.ai)", - hint: "Kimi K2.5", + label: "Kimi API key (.ai)", + hint: "Kimi K2.5 + Kimi", optionKey: "moonshotApiKey", flagName: "--moonshot-api-key", envVar: "MOONSHOT_API_KEY", @@ -47,17 +44,17 @@ const moonshotPlugin = { applyConfig: (cfg) => applyMoonshotConfig(cfg), wizard: { choiceId: "moonshot-api-key", - choiceLabel: "Moonshot API key (.ai)", + choiceLabel: "Kimi API key (.ai)", groupId: "moonshot", groupLabel: "Moonshot AI (Kimi K2.5)", - groupHint: "Kimi K2.5", + groupHint: "Kimi K2.5 + Kimi", }, }), createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key-cn", - label: "Moonshot API key (.cn)", - hint: "Kimi K2.5", + label: "Kimi API key (.cn)", + hint: "Kimi K2.5 + Kimi", optionKey: "moonshotApiKey", flagName: "--moonshot-api-key", envVar: "MOONSHOT_API_KEY", @@ -67,22 +64,31 @@ const moonshotPlugin = { applyConfig: (cfg) => applyMoonshotConfigCn(cfg), wizard: { choiceId: "moonshot-api-key-cn", - choiceLabel: "Moonshot API key (.cn)", + choiceLabel: "Kimi API key (.cn)", groupId: "moonshot", groupLabel: "Moonshot AI (Kimi K2.5)", - groupHint: "Kimi K2.5", + groupHint: "Kimi K2.5 + Kimi", }, }), ], catalog: { order: "simple", - run: (ctx) => - buildSingleProviderApiKeyCatalog({ - ctx, - providerId: PROVIDER_ID, - buildProvider: buildMoonshotProvider, - allowExplicitBaseUrl: true, - }), + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; + const explicitBaseUrl = + typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; + return { + provider: { + ...buildMoonshotProvider(), + ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), + apiKey, + }, + }; + }, }, wrapStreamFn: (ctx) => { const thinkingType = resolveMoonshotThinkingType({ @@ -92,7 +98,6 @@ const moonshotPlugin = { return createMoonshotThinkingWrapper(ctx.streamFn, thinkingType); }, }); - api.registerMediaUnderstandingProvider(moonshotMediaUnderstandingProvider); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ id: "kimi", diff --git a/extensions/moonshot/onboard.ts b/extensions/moonshot/onboard.ts index 57459b724ce..61cc537a622 100644 --- a/extensions/moonshot/onboard.ts +++ b/extensions/moonshot/onboard.ts @@ -1,8 +1,8 @@ import { applyAgentDefaultModelPrimary, applyProviderConfigWithDefaultModel, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; import { buildMoonshotProvider, MOONSHOT_BASE_URL, diff --git a/extensions/moonshot/provider-catalog.ts b/extensions/moonshot/provider-catalog.ts index 86ab93e6e05..37f7213701e 100644 --- a/extensions/moonshot/provider-catalog.ts +++ b/extensions/moonshot/provider-catalog.ts @@ -1,4 +1,4 @@ -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-models"; export const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; export const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5"; diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 60d78a2dac5..8f56ab2ce4c 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -1,5 +1,5 @@ +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/msteams"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { createMSTeamsPollStoreFs } from "./polls.js"; import { getMSTeamsRuntime } from "./runtime.js"; import { sendMessageMSTeams, sendPollMSTeams } from "./send.js"; diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index a94482b8d43..0b74753dcb6 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -1,21 +1,21 @@ +import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { + applyAccountNameToChannelSection, patchScopedAccountConfig, - prepareScopedSetupConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; +} from "openclaw/plugin-sdk/setup"; import { mergeAllowFromEntries, resolveSetupAccountId, setSetupChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, -} 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 { ChannelSetupInput } from "../../../src/channels/plugins/types.core.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"; +} from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; +import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup"; +import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; import { listNextcloudTalkAccountIds, resolveDefaultNextcloudTalkAccountId, @@ -115,7 +115,7 @@ export function clearNextcloudTalkAccountFields( } as CoreConfig; } -export async function promptNextcloudTalkAllowFrom(params: { +async function promptNextcloudTalkAllowFrom(params: { cfg: CoreConfig; prompter: WizardPrompter; accountId: string; @@ -127,7 +127,7 @@ export async function promptNextcloudTalkAllowFrom(params: { "1) Check the Nextcloud admin panel for user IDs", "2) Or look at the webhook payload logs when someone messages", "3) User IDs are typically lowercase usernames in Nextcloud", - `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "nextcloud-talk")}`, ].join("\n"), "Nextcloud Talk user id", ); @@ -158,7 +158,7 @@ export async function promptNextcloudTalkAllowFrom(params: { }); } -export async function promptNextcloudTalkAllowFromForAccount(params: { +async function promptNextcloudTalkAllowFromForAccount(params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string; @@ -174,7 +174,7 @@ export async function promptNextcloudTalkAllowFromForAccount(params: { }); } -export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { +const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { label: "Nextcloud Talk", channel, policyKey: "channels.nextcloud-talk.dmPolicy", @@ -187,7 +187,7 @@ export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - prepareScopedSetupConfig({ + applyAccountNameToChannelSection({ cfg, channelKey: channel, accountId, @@ -208,7 +208,7 @@ export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { }, applyAccountConfig: ({ cfg, accountId, input }) => { const setupInput = input as NextcloudSetupInput; - const namedConfig = prepareScopedSetupConfig({ + const namedConfig = applyAccountNameToChannelSection({ cfg, channelKey: channel, accountId, diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index 46561f5b274..ecb7b29084d 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -1,22 +1,111 @@ -import { setSetupChannelEnabled } from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import { listNextcloudTalkAccountIds, resolveNextcloudTalkAccount } from "./accounts.js"; +import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/config-runtime"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; +import { + mergeAllowFromEntries, + resolveSetupAccountId, + setSetupChannelEnabled, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; +import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup"; +import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; +import { + listNextcloudTalkAccountIds, + resolveDefaultNextcloudTalkAccountId, + resolveNextcloudTalkAccount, +} from "./accounts.js"; import { clearNextcloudTalkAccountFields, - nextcloudTalkDmPolicy, nextcloudTalkSetupAdapter, normalizeNextcloudTalkBaseUrl, setNextcloudTalkAccountConfig, validateNextcloudTalkBaseUrl, } from "./setup-core.js"; -import type { CoreConfig } from "./types.js"; +import type { CoreConfig, DmPolicy } from "./types.js"; const channel = "nextcloud-talk" as const; const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials"; +function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }) as CoreConfig; +} + +async function promptNextcloudTalkAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const resolved = resolveNextcloudTalkAccount({ cfg: params.cfg, accountId: params.accountId }); + const existingAllowFrom = resolved.config.allowFrom ?? []; + await params.prompter.note( + [ + "1) Check the Nextcloud admin panel for user IDs", + "2) Or look at the webhook payload logs when someone messages", + "3) User IDs are typically lowercase usernames in Nextcloud", + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, + ].join("\n"), + "Nextcloud Talk user id", + ); + + let resolvedIds: string[] = []; + while (resolvedIds.length === 0) { + const entry = await params.prompter.text({ + message: "Nextcloud Talk allowFrom (user id)", + placeholder: "username", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + resolvedIds = String(entry) + .split(/[\n,;]+/g) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + if (resolvedIds.length === 0) { + await params.prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk"); + } + } + + return setNextcloudTalkAccountConfig(params.cfg, params.accountId, { + dmPolicy: "allowlist", + allowFrom: mergeAllowFromEntries( + existingAllowFrom.map((value) => String(value).trim().toLowerCase()), + resolvedIds, + ), + }); +} + +async function promptNextcloudTalkAllowFromForAccount(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = resolveSetupAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), + }); + return await promptNextcloudTalkAllowFrom({ + cfg: params.cfg as CoreConfig, + prompter: params.prompter, + accountId, + }); +} + +const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { + label: "Nextcloud Talk", + channel, + policyKey: "channels.nextcloud-talk.dmPolicy", + allowFromKey: "channels.nextcloud-talk.allowFrom", + getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), + promptAllowFrom: promptNextcloudTalkAllowFromForAccount, +}; + export const nextcloudTalkSetupWizard: ChannelSetupWizard = { channel, stepOrder: "text-first", diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index e284d7b68a6..fca302e75fb 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -1,18 +1,18 @@ +import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { mergeAllowFromEntries, parseSetupEntriesWithParser, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, 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 { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DmPolicy } from "../../../src/config/types.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"; +} from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup"; +import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; import { DEFAULT_RELAYS } from "./default-relays.js"; import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js"; import { resolveNostrAccount } from "./types.js"; diff --git a/extensions/nvidia/provider-catalog.ts b/extensions/nvidia/provider-catalog.ts index f506839fa33..ce66986e20a 100644 --- a/extensions/nvidia/provider-catalog.ts +++ b/extensions/nvidia/provider-catalog.ts @@ -1,4 +1,4 @@ -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-models"; const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; const NVIDIA_DEFAULT_MODEL_ID = "nvidia/llama-3.1-nemotron-70b-instruct"; diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 5386a37d270..6f75f9b08a5 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -6,8 +6,7 @@ import { type ProviderAuthResult, type ProviderDiscoveryContext, } from "openclaw/plugin-sdk/core"; -import { OLLAMA_DEFAULT_BASE_URL } from "../../src/agents/ollama-defaults.js"; -import { resolveOllamaApiBase } from "../../src/agents/ollama-models.js"; +import { OLLAMA_DEFAULT_BASE_URL, resolveOllamaApiBase } from "openclaw/plugin-sdk/provider-models"; const PROVIDER_ID = "ollama"; const DEFAULT_API_KEY = "ollama-local"; diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index e45c9718087..831e49acdd8 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -1,6 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildOpenAISpeechProvider } from "../../src/tts/providers/openai.js"; -import { openaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; +import { buildOpenAISpeechProvider } from "openclaw/plugin-sdk/speech"; import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; import { buildOpenAIProvider } from "./openai-provider.js"; @@ -13,7 +12,6 @@ const openAIPlugin = { api.registerProvider(buildOpenAIProvider()); api.registerProvider(buildOpenAICodexProviderPlugin()); api.registerSpeechProvider(buildOpenAISpeechProvider()); - api.registerMediaUnderstandingProvider(openaiMediaUnderstandingProvider); }, }; diff --git a/extensions/openai/openai-codex-catalog.ts b/extensions/openai/openai-codex-catalog.ts index ecea655547b..11c1d564986 100644 --- a/extensions/openai/openai-codex-catalog.ts +++ b/extensions/openai/openai-codex-catalog.ts @@ -1,4 +1,4 @@ -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-models"; export const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index e8be8bd4eb1..6ea59a2e7a7 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -5,16 +5,20 @@ import type { ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/core"; -import { CODEX_CLI_PROFILE_ID } from "../../src/agents/auth-profiles.js"; -import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles.js"; -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/provider-id.js"; -import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js"; -import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js"; -import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { + CODEX_CLI_PROFILE_ID, + ensureAuthProfileStore, + listProfilesForProvider, + loginOpenAICodexOAuth, + type OAuthCredential, +} from "openclaw/plugin-sdk/provider-auth"; +import { + DEFAULT_CONTEXT_TOKENS, + normalizeModelCompat, + normalizeProviderId, + type ProviderPlugin, +} from "openclaw/plugin-sdk/provider-models"; +import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage"; import { buildOpenAICodexProvider } from "./openai-codex-catalog.js"; import { cloneFirstTemplateModel, diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 9c93ec1bd27..8e97b56573f 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -2,14 +2,14 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { normalizeProviderId } from "../../src/agents/provider-id.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyOpenAIConfig, + normalizeModelCompat, + normalizeProviderId, OPENAI_DEFAULT_MODEL, -} from "../../src/commands/openai-model-default.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; -import type { ProviderPlugin } from "../../src/plugins/types.js"; + type ProviderPlugin, +} from "openclaw/plugin-sdk/provider-models"; import { cloneFirstTemplateModel, findCatalogTemplate, diff --git a/extensions/openai/shared.ts b/extensions/openai/shared.ts index ad469a2f136..2b67454fc07 100644 --- a/extensions/openai/shared.ts +++ b/extensions/openai/shared.ts @@ -1,9 +1,8 @@ -import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { findCatalogTemplate } from "../../src/plugins/provider-catalog.js"; import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, -} from "../../src/plugins/types.js"; +} from "openclaw/plugin-sdk/core"; +import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; export const OPENAI_API_BASE_URL = "https://api.openai.com/v1"; @@ -49,4 +48,18 @@ export function cloneFirstTemplateModel(params: { return undefined; } -export { findCatalogTemplate }; +export 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); +} diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index ddfd9a5858c..09319628684 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { OPENCODE_GO_DEFAULT_MODEL_REF } from "../../src/commands/opencode-go-model-default.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { OPENCODE_GO_DEFAULT_MODEL_REF } from "openclaw/plugin-sdk/provider-models"; import { applyOpencodeGoConfig } from "./onboard.js"; const PROVIDER_ID = "opencode-go"; diff --git a/extensions/opencode-go/onboard.ts b/extensions/opencode-go/onboard.ts index 8ca47a0f9d0..ec5727f9525 100644 --- a/extensions/opencode-go/onboard.ts +++ b/extensions/opencode-go/onboard.ts @@ -1,6 +1,8 @@ -import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js"; -import { OPENCODE_GO_DEFAULT_MODEL_REF } from "../../src/commands/opencode-go-model-default.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; +import { OPENCODE_GO_DEFAULT_MODEL_REF } from "openclaw/plugin-sdk/provider-models"; +import { + applyAgentDefaultModelPrimary, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; export { OPENCODE_GO_DEFAULT_MODEL_REF }; diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index 01ccea24656..4f9bbb1384a 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { OPENCODE_ZEN_DEFAULT_MODEL } from "../../src/commands/opencode-zen-model-default.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { OPENCODE_ZEN_DEFAULT_MODEL } from "openclaw/plugin-sdk/provider-models"; import { applyOpencodeZenConfig } from "./onboard.js"; const PROVIDER_ID = "opencode"; diff --git a/extensions/opencode/onboard.ts b/extensions/opencode/onboard.ts index a308129b688..5bccbb34d8a 100644 --- a/extensions/opencode/onboard.ts +++ b/extensions/opencode/onboard.ts @@ -1,6 +1,8 @@ -import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../../src/agents/opencode-zen-models.js"; -import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; +import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "openclaw/plugin-sdk/provider-models"; +import { + applyAgentDefaultModelPrimary, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; export { OPENCODE_ZEN_DEFAULT_MODEL_REF }; diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index 2246424787a..b4c1d908c4f 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -5,17 +5,15 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { DEFAULT_CONTEXT_TOKENS } from "openclaw/plugin-sdk/provider-models"; import { getOpenRouterModelCapabilities, loadOpenRouterModelCapabilities, -} from "../../src/agents/pi-embedded-runner/openrouter-model-capabilities.js"; -import { createOpenRouterSystemCacheWrapper, createOpenRouterWrapper, isProxyReasoningUnsupported, -} from "../../src/agents/pi-embedded-runner/proxy-stream-wrappers.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +} from "openclaw/plugin-sdk/provider-stream"; import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildOpenrouterProvider } from "./provider-catalog.js"; diff --git a/extensions/openrouter/onboard.ts b/extensions/openrouter/onboard.ts index 03ec7bf86bc..f5662399192 100644 --- a/extensions/openrouter/onboard.ts +++ b/extensions/openrouter/onboard.ts @@ -1,5 +1,7 @@ -import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; +import { + applyAgentDefaultModelPrimary, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto"; diff --git a/extensions/openrouter/provider-catalog.ts b/extensions/openrouter/provider-catalog.ts index cfb5fecf8bf..52be862e34d 100644 --- a/extensions/openrouter/provider-catalog.ts +++ b/extensions/openrouter/provider-catalog.ts @@ -1,4 +1,4 @@ -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-models"; const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; const OPENROUTER_DEFAULT_MODEL_ID = "auto"; diff --git a/extensions/perplexity/index.ts b/extensions/perplexity/index.ts index 513c70d131d..0fe3034a000 100644 --- a/extensions/perplexity/index.ts +++ b/extensions/perplexity/index.ts @@ -1,10 +1,9 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createPluginBackedWebSearchProvider, getScopedCredentialValue, setScopedCredentialValue, -} from "../../src/agents/tools/web-search-plugin-factory.js"; -import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; -import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +} from "openclaw/plugin-sdk/provider-web-search"; const perplexityPlugin = { id: "perplexity", diff --git a/extensions/qianfan/index.ts b/extensions/qianfan/index.ts index 04bd8429755..e8f2f2cc59d 100644 --- a/extensions/qianfan/index.ts +++ b/extensions/qianfan/index.ts @@ -1,5 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildQianfanProvider } from "./provider-catalog.js"; diff --git a/extensions/qianfan/onboard.ts b/extensions/qianfan/onboard.ts index 6df59e49a40..c389868c7d8 100644 --- a/extensions/qianfan/onboard.ts +++ b/extensions/qianfan/onboard.ts @@ -1,9 +1,9 @@ import { applyAgentDefaultModelPrimary, applyProviderConfigWithDefaultModels, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; -import type { ModelApi } from "../../src/config/types.models.js"; + type ModelApi, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; import { buildQianfanProvider, QIANFAN_BASE_URL, diff --git a/extensions/qianfan/provider-catalog.ts b/extensions/qianfan/provider-catalog.ts index f96fca8e14c..c8aee208a8e 100644 --- a/extensions/qianfan/provider-catalog.ts +++ b/extensions/qianfan/provider-catalog.ts @@ -1,4 +1,4 @@ -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-models"; export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2"; export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2"; diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index 7c64c9b7683..2a9538a33ab 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -1,3 +1,5 @@ +import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/agent-runtime"; +import { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime"; import { buildOauthProviderAuthResult, emptyPluginConfigSchema, @@ -5,9 +7,7 @@ import { type ProviderAuthContext, type ProviderCatalogContext, } from "openclaw/plugin-sdk/qwen-portal-auth"; -import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; -import { QWEN_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js"; -import { refreshQwenPortalCredentials } from "../../src/providers/qwen-portal-oauth.js"; +import { refreshQwenPortalCredentials } from "openclaw/plugin-sdk/qwen-portal-auth"; import { loginQwenPortalOAuth } from "./oauth.js"; import { buildQwenPortalProvider, QWEN_PORTAL_BASE_URL } from "./provider-catalog.js"; diff --git a/extensions/qwen-portal-auth/provider-catalog.ts b/extensions/qwen-portal-auth/provider-catalog.ts index aa038c0810e..f8d350fc2da 100644 --- a/extensions/qwen-portal-auth/provider-catalog.ts +++ b/extensions/qwen-portal-auth/provider-catalog.ts @@ -1,4 +1,7 @@ -import type { ModelDefinitionConfig, ModelProviderConfig } from "../../src/config/types.models.js"; +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-models"; export const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1"; const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000; diff --git a/extensions/sglang/index.ts b/extensions/sglang/index.ts index fc7522ef15b..9918c7ee98b 100644 --- a/extensions/sglang/index.ts +++ b/extensions/sglang/index.ts @@ -1,14 +1,14 @@ -import { - emptyPluginConfigSchema, - 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"; +} from "openclaw/plugin-sdk/agent-runtime"; +import { + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderAuthMethodNonInteractiveContext, +} from "openclaw/plugin-sdk/core"; const PROVIDER_ID = "sglang"; diff --git a/extensions/signal/src/accounts.ts b/extensions/signal/src/accounts.ts index 30a3b56189c..456db907685 100644 --- a/extensions/signal/src/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -1,10 +1,10 @@ import { - type OpenClawConfig, createAccountListHelpers, normalizeAccountId, resolveAccountEntry, -} from "../../../src/plugin-sdk-internal/accounts.js"; -import type { SignalAccountConfig } from "../../../src/plugin-sdk-internal/signal.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/account-resolution"; +import type { SignalAccountConfig } from "openclaw/plugin-sdk/signal"; export type ResolvedSignalAccount = { accountId: string; diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index d633ff6a251..b81d10cc99d 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -1,9 +1,94 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/signal"; -import { type ResolvedSignalAccount } from "./accounts.js"; +import { + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + getChatChannelMeta, + normalizeE164, + setAccountEnabledInConfigSection, + SignalConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/signal"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, + type ResolvedSignalAccount, +} from "./accounts.js"; +import { signalConfigAccessors, signalSetupWizard } from "./plugin-shared.js"; import { signalSetupAdapter } from "./setup-core.js"; -import { createSignalPluginBase, signalSetupWizard } from "./shared.js"; -export const signalSetupPlugin: ChannelPlugin = createSignalPluginBase({ +export const signalSetupPlugin: ChannelPlugin = { + id: "signal", + meta: { + ...getChatChannelMeta("signal"), + }, setupWizard: signalSetupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.signal"] }, + configSchema: buildChannelConfigSchema(SignalConfigSchema), + config: { + listAccountIds: (cfg) => listSignalAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "signal", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "signal", + accountId, + clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + }), + ...signalConfigAccessors, + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "signal", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), + }), + collectWarnings: ({ account, cfg }) => + collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.signal !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "Signal groups", + openScope: "any member", + groupPolicyPath: "channels.signal.groupPolicy", + groupAllowFromPath: "channels.signal.groupAllowFrom", + mentionGated: false, + }), + }, setup: signalSetupAdapter, -}); +}; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 2b392bbacf2..aba60d3e29a 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,21 +1,31 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat"; +import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core"; +import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, + buildChannelConfigSchema, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + getChatChannelMeta, looksLikeSignalTargetId, + normalizeE164, normalizeSignalMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, + setAccountEnabledInConfigSection, + SignalConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, } from "openclaw/plugin-sdk/signal"; -import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; -import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, @@ -29,10 +39,10 @@ import { resolveSignalRecipient, resolveSignalSender, } from "./identity.js"; +import { signalConfigAccessors, signalSetupWizard } from "./plugin-shared.js"; import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; import { signalSetupAdapter } from "./setup-core.js"; -import { createSignalPluginBase, signalConfigAccessors, signalSetupWizard } from "./shared.js"; const signalMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [], @@ -282,10 +292,11 @@ async function sendFormattedSignalMedia(ctx: { } export const signalPlugin: ChannelPlugin = { - ...createSignalPluginBase({ - setupWizard: signalSetupWizard, - setup: signalSetupAdapter, - }), + id: "signal", + meta: { + ...getChatChannelMeta("signal"), + }, + setupWizard: signalSetupWizard, pairing: { idLabel: "signalNumber", normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""), @@ -293,7 +304,46 @@ export const signalPlugin: ChannelPlugin = { await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE); }, }, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: true, + }, actions: signalMessageActions, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.signal"] }, + configSchema: buildChannelConfigSchema(SignalConfigSchema), + config: { + listAccountIds: (cfg) => listSignalAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "signal", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "signal", + accountId, + clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + }), + ...signalConfigAccessors, + }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => { @@ -315,6 +365,32 @@ export const signalPlugin: ChannelPlugin = { }), }), }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + return buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "signal", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), + }); + }, + collectWarnings: ({ account, cfg }) => { + return collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.signal !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "Signal groups", + openScope: "any member", + groupPolicyPath: "channels.signal.groupPolicy", + groupAllowFromPath: "channels.signal.groupAllowFrom", + mentionGated: false, + }); + }, + }, messaging: { normalizeTarget: normalizeSignalMessagingTarget, parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw), @@ -325,6 +401,7 @@ export const signalPlugin: ChannelPlugin = { hint: "", }, }, + setup: signalSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit), diff --git a/extensions/signal/src/client.ts b/extensions/signal/src/client.ts index 394aec4e297..4a6d63bd685 100644 --- a/extensions/signal/src/client.ts +++ b/extensions/signal/src/client.ts @@ -1,6 +1,6 @@ -import { resolveFetch } from "../../../src/infra/fetch.js"; -import { generateSecureUuid } from "../../../src/infra/secure-random.js"; -import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js"; +import { resolveFetch } from "openclaw/plugin-sdk/infra-runtime"; +import { generateSecureUuid } from "openclaw/plugin-sdk/infra-runtime"; +import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime"; export type SignalRpcOptions = { baseUrl: string; diff --git a/extensions/signal/src/daemon.ts b/extensions/signal/src/daemon.ts index d53597a296b..028b9fbe964 100644 --- a/extensions/signal/src/daemon.ts +++ b/extensions/signal/src/daemon.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import type { RuntimeEnv } from "../../../src/runtime.js"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; export type SignalDaemonOpts = { cliPath: string; diff --git a/extensions/signal/src/format.ts b/extensions/signal/src/format.ts index 2180693293e..73574832df8 100644 --- a/extensions/signal/src/format.ts +++ b/extensions/signal/src/format.ts @@ -1,10 +1,10 @@ -import type { MarkdownTableMode } from "../../../src/config/types.base.js"; +import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { chunkMarkdownIR, markdownToIR, type MarkdownIR, type MarkdownStyle, -} from "../../../src/markdown/ir.js"; +} from "openclaw/plugin-sdk/text-runtime"; type SignalTextStyle = "BOLD" | "ITALIC" | "STRIKETHROUGH" | "MONOSPACE" | "SPOILER"; diff --git a/extensions/signal/src/identity.ts b/extensions/signal/src/identity.ts index 464713559c3..dbd86ca1584 100644 --- a/extensions/signal/src/identity.ts +++ b/extensions/signal/src/identity.ts @@ -1,5 +1,5 @@ -import { evaluateSenderGroupAccessForPolicy } from "../../../src/plugin-sdk-internal/signal.js"; -import { normalizeE164 } from "../../../src/utils.js"; +import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; +import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; export type SignalSender = | { kind: "phone"; raw: string; e164: string } diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts index 252e039b0fb..10cf32b383a 100644 --- a/extensions/signal/src/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -1,7 +1,7 @@ +import { resetSystemEventsForTest } from "openclaw/plugin-sdk/infra-runtime"; +import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; +import type { MockFn } from "openclaw/plugin-sdk/test-utils"; import { beforeEach, vi } from "vitest"; -import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; -import { resetSystemEventsForTest } from "../../../src/infra/system-events.js"; -import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js"; type SignalToolResultTestMocks = { @@ -68,15 +68,15 @@ export function createMockSignalDaemonHandle( }; } -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => config, }; }); -vi.mock("../../../src/auto-reply/reply.js", () => ({ +vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ getReplyFromConfig: (...args: unknown[]) => replyMock(...args), })); @@ -86,13 +86,13 @@ vi.mock("./send.js", () => ({ sendReadReceiptSignal: vi.fn().mockResolvedValue(true), })); -vi.mock("../../../src/pairing/pairing-store.js", () => ({ +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), })); -vi.mock("../../../src/config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), @@ -116,7 +116,7 @@ vi.mock("./daemon.js", async (importOriginal) => { }; }); -vi.mock("../../../src/infra/transport-ready.js", () => ({ +vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({ waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args), })); diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 3febfe740d4..02fd94ff8b8 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -1,27 +1,24 @@ -import { - chunkTextWithMode, - resolveChunkMode, - resolveTextChunkLimit, -} from "../../../src/auto-reply/chunk.js"; -import { - DEFAULT_GROUP_HISTORY_LIMIT, - type HistoryEntry, -} from "../../../src/auto-reply/reply/history.js"; -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { loadConfig } from "../../../src/config/config.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, -} from "../../../src/config/runtime-group-policy.js"; -import type { SignalReactionNotificationMode } from "../../../src/config/types.js"; -import type { BackoffPolicy } from "../../../src/infra/backoff.js"; -import { waitForTransportReady } from "../../../src/infra/transport-ready.js"; -import { saveMediaBuffer } from "../../../src/media/store.js"; -import { createNonExitingRuntime, type RuntimeEnv } from "../../../src/runtime.js"; -import { normalizeStringEntries } from "../../../src/shared/string-normalization.js"; -import { normalizeE164 } from "../../../src/utils.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime"; +import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime"; +import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime"; +import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; +import { + chunkTextWithMode, + resolveChunkMode, + resolveTextChunkLimit, +} from "openclaw/plugin-sdk/reply-runtime"; +import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; +import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { resolveSignalAccount } from "./accounts.js"; import { signalCheck, signalRpcRequest } from "./client.js"; import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js"; diff --git a/extensions/signal/src/monitor/access-policy.ts b/extensions/signal/src/monitor/access-policy.ts index 72555186031..de083efd9fd 100644 --- a/extensions/signal/src/monitor/access-policy.ts +++ b/extensions/signal/src/monitor/access-policy.ts @@ -1,9 +1,9 @@ -import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; +import { issuePairingChallenge } from "openclaw/plugin-sdk/conversation-runtime"; +import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, -} from "../../../../src/security/dm-policy-shared.js"; +} from "openclaw/plugin-sdk/security-runtime"; import { isSignalSenderAllowed, type SignalSender } from "../identity.js"; type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled"; diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 36eb0e8d276..c8f9da661a0 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -1,44 +1,41 @@ -import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; -import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js"; -import { dispatchInboundMessage } from "../../../../src/auto-reply/dispatch.js"; +import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; +import { + createChannelInboundDebouncer, + shouldDebounceTextInbound, +} from "openclaw/plugin-sdk/channel-runtime"; +import { logInboundDrop, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime"; +import { normalizeSignalMessagingTarget } from "openclaw/plugin-sdk/channel-runtime"; +import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; +import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/config-runtime"; +import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; +import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { formatInboundEnvelope, formatInboundFromLabel, resolveEnvelopeFormatOptions, -} from "../../../../src/auto-reply/envelope.js"; +} from "openclaw/plugin-sdk/reply-runtime"; import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, recordPendingHistoryEntryIfEnabled, -} from "../../../../src/auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; -import { - buildMentionRegexes, - matchesMentionPatterns, -} from "../../../../src/auto-reply/reply/mentions.js"; -import { createReplyDispatcherWithTyping } from "../../../../src/auto-reply/reply/reply-dispatcher.js"; -import { resolveControlCommandGate } from "../../../../src/channels/command-gating.js"; -import { - createChannelInboundDebouncer, - shouldDebounceTextInbound, -} from "../../../../src/channels/inbound-debounce-policy.js"; -import { logInboundDrop, logTypingFailure } from "../../../../src/channels/logging.js"; -import { resolveMentionGatingWithBypass } from "../../../../src/channels/mention-gating.js"; -import { normalizeSignalMessagingTarget } from "../../../../src/channels/plugins/normalize/signal.js"; -import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; -import { recordInboundSession } from "../../../../src/channels/session.js"; -import { createTypingCallbacks } from "../../../../src/channels/typing.js"; -import { resolveChannelGroupRequireMention } from "../../../../src/config/group-policy.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; -import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; -import { kindFromMime } from "../../../../src/media/mime.js"; -import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +} from "openclaw/plugin-sdk/reply-runtime"; +import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; +import { buildMentionRegexes, matchesMentionPatterns } from "openclaw/plugin-sdk/reply-runtime"; +import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { DM_GROUP_ACCESS_REASON, resolvePinnedMainDmOwnerFromAllowlist, -} from "../../../../src/security/dm-policy-shared.js"; -import { normalizeE164 } from "../../../../src/utils.js"; +} from "openclaw/plugin-sdk/security-runtime"; +import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { formatSignalPairingIdLine, formatSignalSenderDisplay, diff --git a/extensions/signal/src/monitor/event-handler.types.ts b/extensions/signal/src/monitor/event-handler.types.ts index c1d0b0b3881..82a96af73cc 100644 --- a/extensions/signal/src/monitor/event-handler.types.ts +++ b/extensions/signal/src/monitor/event-handler.types.ts @@ -1,12 +1,12 @@ -import type { HistoryEntry } from "../../../../src/auto-reply/reply/history.js"; -import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { DmPolicy, GroupPolicy, SignalReactionNotificationMode, -} from "../../../../src/config/types.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { SignalSender } from "../identity.js"; export type SignalEnvelope = { diff --git a/extensions/signal/src/outbound-adapter.ts b/extensions/signal/src/outbound-adapter.ts index b0d77c12bd0..cd61b825981 100644 --- a/extensions/signal/src/outbound-adapter.ts +++ b/extensions/signal/src/outbound-adapter.ts @@ -1,11 +1,8 @@ -import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; -import { createScopedChannelMediaMaxBytesResolver } from "../../../src/channels/plugins/outbound/direct-text-media.js"; -import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; -import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; -import { - resolveOutboundSendDep, - type OutboundSendDeps, -} from "../../../src/infra/outbound/send-deps.js"; +import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { markdownToSignalTextChunks } from "./format.js"; import { sendMessageSignal } from "./send.js"; diff --git a/extensions/signal/src/plugin-shared.ts b/extensions/signal/src/plugin-shared.ts index 60559f09dcb..a5713e4c361 100644 --- a/extensions/signal/src/plugin-shared.ts +++ b/extensions/signal/src/plugin-shared.ts @@ -1,5 +1,5 @@ -import { createScopedAccountConfigAccessors } from "../../../src/plugin-sdk-internal/channel-config.js"; -import { normalizeE164, type OpenClawConfig } from "../../../src/plugin-sdk-internal/signal.js"; +import { createScopedAccountConfigAccessors } from "openclaw/plugin-sdk/channel-config-helpers"; +import { normalizeE164, type OpenClawConfig } from "openclaw/plugin-sdk/signal"; import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; import { createSignalSetupWizardProxy } from "./setup-core.js"; diff --git a/extensions/signal/src/probe.ts b/extensions/signal/src/probe.ts index bf200effd6d..ac7dce428e8 100644 --- a/extensions/signal/src/probe.ts +++ b/extensions/signal/src/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; import { signalCheck, signalRpcRequest } from "./client.js"; export type SignalProbe = BaseProbeResult & { diff --git a/extensions/signal/src/reaction-level.ts b/extensions/signal/src/reaction-level.ts index 884bccec58e..2211b9f261a 100644 --- a/extensions/signal/src/reaction-level.ts +++ b/extensions/signal/src/reaction-level.ts @@ -1,9 +1,9 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveReactionLevel, type ReactionLevel, type ResolvedReactionLevel, -} from "../../../src/utils/reaction-level.js"; +} from "openclaw/plugin-sdk/text-runtime"; import { resolveSignalAccount } from "./accounts.js"; export type SignalReactionLevel = ReactionLevel; diff --git a/extensions/signal/src/rpc-context.ts b/extensions/signal/src/rpc-context.ts index 54c123cc6be..255338379d4 100644 --- a/extensions/signal/src/rpc-context.ts +++ b/extensions/signal/src/rpc-context.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../../../src/config/config.js"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveSignalAccount } from "./accounts.js"; export function resolveSignalRpcContext( diff --git a/extensions/signal/src/runtime.ts b/extensions/signal/src/runtime.ts index 99bdf04a447..9790195f0e8 100644 --- a/extensions/signal/src/runtime.ts +++ b/extensions/signal/src/runtime.ts @@ -1,7 +1,5 @@ -import { - createPluginRuntimeStore, - type PluginRuntime, -} from "../../../src/plugin-sdk-internal/core.js"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } = createPluginRuntimeStore("Signal runtime not initialized"); diff --git a/extensions/signal/src/send-reactions.ts b/extensions/signal/src/send-reactions.ts index a5000ca9e8f..6b8c3791b2d 100644 --- a/extensions/signal/src/send-reactions.ts +++ b/extensions/signal/src/send-reactions.ts @@ -2,8 +2,8 @@ * Signal reactions via signal-cli JSON-RPC API */ -import { loadConfig } from "../../../src/config/config.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveSignalAccount } from "./accounts.js"; import { signalRpcRequest } from "./client.js"; import { resolveSignalRpcContext } from "./rpc-context.js"; diff --git a/extensions/signal/src/send.ts b/extensions/signal/src/send.ts index bb953680290..c102624836e 100644 --- a/extensions/signal/src/send.ts +++ b/extensions/signal/src/send.ts @@ -1,7 +1,7 @@ -import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js"; -import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; -import { kindFromMime } from "../../../src/media/mime.js"; -import { resolveOutboundAttachmentFromUrl } from "../../../src/media/outbound-attachment.js"; +import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; +import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime"; import { resolveSignalAccount } from "./accounts.js"; import { signalRpcRequest } from "./client.js"; import { markdownToSignalText, type SignalTextStyleRange } from "./format.js"; diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 5e3901f0fae..1e479c38dc6 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,5 +1,10 @@ -import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import { + applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + formatCliCommand, + formatDocsLink, + migrateBaseNameToDefaultAccount, + normalizeAccountId, normalizeE164, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, @@ -7,13 +12,12 @@ import { setSetupChannelEnabled, type OpenClawConfig, type WizardPrompter, -} from "../../../src/plugin-sdk-internal/setup.js"; +} from "openclaw/plugin-sdk/setup"; import type { ChannelSetupAdapter, ChannelSetupDmPolicy, ChannelSetupWizard, -} from "../../../src/plugin-sdk-internal/setup.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +} from "openclaw/plugin-sdk/setup"; import { listSignalAccountIds, resolveDefaultSignalAccountId, @@ -24,7 +28,7 @@ const channel = "signal" as const; const MIN_E164_DIGITS = 5; const MAX_E164_DIGITS = 15; const DIGITS_ONLY = /^\d+$/; -export const INVALID_SIGNAL_ACCOUNT_ERROR = +const INVALID_SIGNAL_ACCOUNT_ERROR = "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; export function normalizeSignalAccountInput(value: string | null | undefined): string | null { @@ -83,7 +87,7 @@ function buildSignalSetupPatch(input: { }; } -export async function promptSignalAllowFrom(params: { +async function promptSignalAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string; @@ -111,8 +115,15 @@ export async function promptSignalAllowFrom(params: { }); } -export const signalSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ - channelKey: channel, +export const signalSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), validateInput: ({ input }) => { if ( !input.signalNumber && @@ -125,40 +136,74 @@ export const signalSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetup } return null; }, - buildPatch: (input) => buildSignalSetupPatch(input), -}); - -type SignalSetupWizardHandlers = { - resolveStatusLines: NonNullable["resolveStatusLines"]; - resolveSelectionHint: NonNullable["resolveSelectionHint"]; - resolveQuickstartScore: NonNullable["resolveQuickstartScore"]; - prepare?: ChannelSetupWizard["prepare"]; - shouldPromptCliPath: NonNullable< - NonNullable[number]["shouldPrompt"] - >; + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + signal: { + ...next.channels?.signal, + enabled: true, + ...buildSignalSetupPatch(input), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + signal: { + ...next.channels?.signal, + enabled: true, + accounts: { + ...next.channels?.signal?.accounts, + [accountId]: { + ...next.channels?.signal?.accounts?.[accountId], + enabled: true, + ...buildSignalSetupPatch(input), + }, + }, + }, + }, + }; + }, }; -export function createSignalSetupWizardBase( - handlers: SignalSetupWizardHandlers, -): ChannelSetupWizard { - const setupChannel = "signal" as const; +export function createSignalSetupWizardProxy( + loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, +) { const signalDmPolicy: ChannelSetupDmPolicy = { label: "Signal", - channel: setupChannel, + channel, policyKey: "channels.signal.dmPolicy", allowFromKey: "channels.signal.allowFrom", getCurrent: (cfg: OpenClawConfig) => cfg.channels?.signal?.dmPolicy ?? "pairing", setPolicy: (cfg: OpenClawConfig, policy) => setChannelDmPolicyWithAllowFrom({ cfg, - channel: setupChannel, + channel, dmPolicy: policy, }), promptAllowFrom: promptSignalAllowFrom, }; return { - channel: setupChannel, + channel, status: { configuredLabel: "configured", unconfiguredLabel: "needs setup", @@ -170,11 +215,14 @@ export function createSignalSetupWizardBase( listSignalAccountIds(cfg).some( (accountId) => resolveSignalAccount({ cfg, accountId }).configured, ), - resolveStatusLines: handlers.resolveStatusLines, - resolveSelectionHint: handlers.resolveSelectionHint, - resolveQuickstartScore: handlers.resolveQuickstartScore, + resolveStatusLines: async (params) => + (await loadWizard()).signalSetupWizard.status.resolveStatusLines?.(params) ?? [], + resolveSelectionHint: async (params) => + await (await loadWizard()).signalSetupWizard.status.resolveSelectionHint?.(params), + resolveQuickstartScore: async (params) => + await (await loadWizard()).signalSetupWizard.status.resolveQuickstartScore?.(params), }, - prepare: handlers.prepare, + prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params), credentials: [], textInputs: [ { @@ -188,7 +236,12 @@ export function createSignalSetupWizardBase( (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? resolveSignalAccount({ cfg, accountId }).config.cliPath ?? "signal-cli", - shouldPrompt: handlers.shouldPromptCliPath, + shouldPrompt: async (params) => { + const input = (await loadWizard()).signalSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }, confirmCurrentValue: false, applyCurrentValue: true, helpTitle: "Signal", @@ -213,31 +266,11 @@ export function createSignalSetupWizardBase( lines: [ 'Link device with: signal-cli link -n "OpenClaw"', "Scan QR in Signal -> Linked Devices", - `Then run: openclaw gateway call channels.status --params '{"probe":true}'`, - "Docs: https://docs.openclaw.ai/signal", + `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, + `Docs: ${formatDocsLink("/signal", "signal")}`, ], }, dmPolicy: signalDmPolicy, - disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, setupChannel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } - -export function createSignalSetupWizardProxy( - loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, -) { - return createSignalSetupWizardBase({ - resolveStatusLines: async (params) => - (await loadWizard()).signalSetupWizard.status.resolveStatusLines?.(params) ?? [], - resolveSelectionHint: async (params) => - await (await loadWizard()).signalSetupWizard.status.resolveSelectionHint?.(params), - resolveQuickstartScore: async (params) => - await (await loadWizard()).signalSetupWizard.status.resolveQuickstartScore?.(params), - prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params), - shouldPromptCliPath: async (params) => { - const input = (await loadWizard()).signalSetupWizard.textInputs?.find( - (entry) => entry.inputKey === "cliPath", - ); - return (await input?.shouldPrompt?.(params)) ?? false; - }, - }); -} diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index e3ac6f7e42a..32270cde952 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,34 +1,104 @@ import { + DEFAULT_ACCOUNT_ID, detectBinary, + formatCliCommand, + formatDocsLink, installSignalCli, type OpenClawConfig, -} from "../../../src/plugin-sdk-internal/setup.js"; -import type { ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; -import { resolveSignalAccount } from "./accounts.js"; + parseSetupEntriesAllowingWildcard, + promptParsedAllowFromForScopedChannel, + setChannelDmPolicyWithAllowFrom, + setSetupChannelEnabled, + type WizardPrompter, +} from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, +} from "./accounts.js"; import { - createSignalSetupWizardBase, - INVALID_SIGNAL_ACCOUNT_ERROR, normalizeSignalAccountInput, - promptSignalAllowFrom, + parseSignalAllowFromEntries, signalSetupAdapter, } from "./setup-core.js"; -export const signalSetupWizard: ChannelSetupWizard = createSignalSetupWizardBase({ - resolveStatusLines: async ({ cfg, configured }) => { - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - const signalCliDetected = await detectBinary(signalCliPath); - return [ - `Signal: ${configured ? "configured" : "needs setup"}`, - `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, - ]; - }, - resolveSelectionHint: async ({ cfg }) => { - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - return (await detectBinary(signalCliPath)) ? "signal-cli found" : "signal-cli missing"; - }, - resolveQuickstartScore: async ({ cfg }) => { - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - return (await detectBinary(signalCliPath)) ? 1 : 0; +const channel = "signal" as const; +const INVALID_SIGNAL_ACCOUNT_ERROR = + "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; + +async function promptSignalAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + return promptParsedAllowFromForScopedChannel({ + cfg: params.cfg, + channel, + accountId: params.accountId, + defaultAccountId: resolveDefaultSignalAccountId(params.cfg), + prompter: params.prompter, + noteTitle: "Signal allowlist", + noteLines: [ + "Allowlist Signal DMs by sender id.", + "Examples:", + "- +15555550123", + "- uuid:123e4567-e89b-12d3-a456-426614174000", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/signal", "signal")}`, + ], + message: "Signal allowFrom (E.164 or uuid)", + placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000", + parseEntries: parseSignalAllowFromEntries, + getExistingAllowFrom: ({ cfg, accountId }) => + resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? [], + }); +} + +const signalDmPolicy: ChannelSetupDmPolicy = { + label: "Signal", + channel, + policyKey: "channels.signal.dmPolicy", + allowFromKey: "channels.signal.allowFrom", + getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptSignalAllowFrom, +}; + +export const signalSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "signal-cli found", + unconfiguredHint: "signal-cli missing", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listSignalAccountIds(cfg).some( + (accountId) => resolveSignalAccount({ cfg, accountId }).configured, + ), + resolveStatusLines: async ({ cfg, configured }) => { + const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; + const signalCliDetected = await detectBinary(signalCliPath); + return [ + `Signal: ${configured ? "configured" : "needs setup"}`, + `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, + ]; + }, + resolveSelectionHint: async ({ cfg }) => { + const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; + return (await detectBinary(signalCliPath)) ? "signal-cli found" : "signal-cli missing"; + }, + resolveQuickstartScore: async ({ cfg }) => { + const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; + return (await detectBinary(signalCliPath)) ? 1 : 0; + }, }, prepare: async ({ cfg, accountId, credentialValues, runtime, prompter, options }) => { if (!options?.allowSignalInstall) { @@ -65,13 +135,50 @@ export const signalSetupWizard: ChannelSetupWizard = createSignalSetupWizardBase await prompter.note(`signal-cli install failed: ${String(error)}`, "Signal"); } }, - shouldPromptCliPath: async ({ currentValue }) => - !(await detectBinary(currentValue ?? "signal-cli")), -}); - -export { - INVALID_SIGNAL_ACCOUNT_ERROR, - normalizeSignalAccountInput, - promptSignalAllowFrom, - signalSetupAdapter, + credentials: [], + textInputs: [ + { + inputKey: "cliPath", + message: "signal-cli path", + currentValue: ({ cfg, accountId, credentialValues }) => + (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? + resolveSignalAccount({ cfg, accountId }).config.cliPath ?? + "signal-cli", + initialValue: ({ cfg, accountId, credentialValues }) => + (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? + resolveSignalAccount({ cfg, accountId }).config.cliPath ?? + "signal-cli", + shouldPrompt: async ({ currentValue }) => !(await detectBinary(currentValue ?? "signal-cli")), + confirmCurrentValue: false, + applyCurrentValue: true, + helpTitle: "Signal", + helpLines: [ + "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", + ], + }, + { + inputKey: "signalNumber", + message: "Signal bot number (E.164)", + currentValue: ({ cfg, accountId }) => + normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ?? + undefined, + keepPrompt: (value) => `Signal account set (${value}). Keep it?`, + validate: ({ value }) => + normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR, + normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value, + }, + ], + completionNote: { + title: "Signal next steps", + lines: [ + 'Link device with: signal-cli link -n "OpenClaw"', + "Scan QR in Signal -> Linked Devices", + `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, + `Docs: ${formatDocsLink("/signal", "signal")}`, + ], + }, + dmPolicy: signalDmPolicy, + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; + +export { normalizeSignalAccountInput, parseSignalAllowFromEntries, signalSetupAdapter }; diff --git a/extensions/signal/src/sse-reconnect.ts b/extensions/signal/src/sse-reconnect.ts index 240ec7a4beb..f825a211afb 100644 --- a/extensions/signal/src/sse-reconnect.ts +++ b/extensions/signal/src/sse-reconnect.ts @@ -1,7 +1,7 @@ -import { logVerbose, shouldLogVerbose } from "../../../src/globals.js"; -import type { BackoffPolicy } from "../../../src/infra/backoff.js"; -import { computeBackoff, sleepWithAbort } from "../../../src/infra/backoff.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; +import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime"; +import { computeBackoff, sleepWithAbort } from "openclaw/plugin-sdk/infra-runtime"; +import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { type SignalSseEvent, streamSignalEvents } from "./client.js"; const DEFAULT_RECONNECT_POLICY: BackoffPolicy = { diff --git a/extensions/slack/src/account-inspect.ts b/extensions/slack/src/account-inspect.ts index 1cc3f2b8509..7ea7ef042c2 100644 --- a/extensions/slack/src/account-inspect.ts +++ b/extensions/slack/src/account-inspect.ts @@ -1,13 +1,13 @@ import { hasConfiguredSecretInput, normalizeSecretInputString, -} from "../../../src/config/types.secrets.js"; +} from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig, type SlackAccountConfig, -} from "../../../src/plugin-sdk-internal/slack.js"; +} from "openclaw/plugin-sdk/slack"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { mergeSlackAccountConfig, diff --git a/extensions/slack/src/account-surface-fields.ts b/extensions/slack/src/account-surface-fields.ts index 8913a9859fe..be264d9d369 100644 --- a/extensions/slack/src/account-surface-fields.ts +++ b/extensions/slack/src/account-surface-fields.ts @@ -1,4 +1,4 @@ -import type { SlackAccountConfig } from "../../../src/config/types.js"; +import type { SlackAccountConfig } from "openclaw/plugin-sdk/config-runtime"; export type SlackAccountSurfaceFields = { groupPolicy?: SlackAccountConfig["groupPolicy"]; diff --git a/extensions/slack/src/accounts.ts b/extensions/slack/src/accounts.ts index 4297e74902b..e453067e485 100644 --- a/extensions/slack/src/accounts.ts +++ b/extensions/slack/src/accounts.ts @@ -1,12 +1,12 @@ import { - type OpenClawConfig, createAccountListHelpers, DEFAULT_ACCOUNT_ID, normalizeAccountId, normalizeChatType, resolveAccountEntry, -} from "../../../src/plugin-sdk-internal/accounts.js"; -import type { SlackAccountConfig } from "../../../src/plugin-sdk-internal/slack.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/account-resolution"; +import type { SlackAccountConfig } from "openclaw/plugin-sdk/slack"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js"; diff --git a/extensions/slack/src/actions.ts b/extensions/slack/src/actions.ts index ba422ac50f2..20b32d15726 100644 --- a/extensions/slack/src/actions.ts +++ b/extensions/slack/src/actions.ts @@ -1,6 +1,6 @@ import type { Block, KnownBlock, WebClient } from "@slack/web-api"; -import { loadConfig } from "../../../src/config/config.js"; -import { logVerbose } from "../../../src/globals.js"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveSlackAccount } from "./accounts.js"; import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; import { validateSlackBlocksArray } from "./blocks-input.js"; diff --git a/extensions/slack/src/blocks-render.ts b/extensions/slack/src/blocks-render.ts index f22b179223d..775b988c521 100644 --- a/extensions/slack/src/blocks-render.ts +++ b/extensions/slack/src/blocks-render.ts @@ -1,6 +1,6 @@ import type { Block, KnownBlock } from "@slack/web-api"; -import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js"; -import type { InteractiveReply } from "../../../src/interactive/payload.js"; +import { reduceInteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; +import type { InteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; import { truncateSlackText } from "./truncate.js"; export const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button"; diff --git a/extensions/slack/src/blocks.test-helpers.ts b/extensions/slack/src/blocks.test-helpers.ts index 50f7d66b04d..3ee978a2d81 100644 --- a/extensions/slack/src/blocks.test-helpers.ts +++ b/extensions/slack/src/blocks.test-helpers.ts @@ -17,7 +17,7 @@ export type SlackSendTestClient = WebClient & { }; export function installSlackBlockTestMocks() { - vi.mock("../../../src/config/config.js", () => ({ + vi.mock("openclaw/plugin-sdk/config-runtime", () => ({ loadConfig: () => ({}), })); diff --git a/extensions/slack/src/channel-migration.ts b/extensions/slack/src/channel-migration.ts index e78ade084d4..f6b97eb798a 100644 --- a/extensions/slack/src/channel-migration.ts +++ b/extensions/slack/src/channel-migration.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { SlackChannelConfig } from "../../../src/config/types.slack.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { SlackChannelConfig } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; type SlackChannels = Record; diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index 003c33e04b4..0eaf3053aa2 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -1,19 +1,61 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/slack"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + SlackConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/slack"; import { type ResolvedSlackAccount } from "./accounts.js"; -import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; -import { createSlackPluginBase } from "./shared.js"; - -async function loadSlackChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ - slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, -})); +import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; +import { + isSlackPluginAccountConfigured, + slackConfigAccessors, + slackConfigBase, + slackSetupWizard, +} from "./plugin-shared.js"; +import { slackSetupAdapter } from "./setup-core.js"; export const slackSetupPlugin: ChannelPlugin = { - ...createSlackPluginBase({ - setupWizard: slackSetupWizard, - setup: slackSetupAdapter, - }), + id: "slack", + meta: { + ...getChatChannelMeta("slack"), + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: slackSetupWizard, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + agentPrompt: { + messageToolHints: ({ cfg, accountId }) => + isSlackInteractiveRepliesEnabled({ cfg, accountId }) + ? [ + "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", + "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", + ] + : [ + "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", + ], + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.slack"] }, + configSchema: buildChannelConfigSchema(SlackConfigSchema), + config: { + ...slackConfigBase, + isConfigured: (account) => isSlackPluginAccountConfigured(account), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: isSlackPluginAccountConfigured(account), + botTokenSource: account.botTokenSource, + appTokenSource: account.appTokenSource, + }), + ...slackConfigAccessors, + }, + setup: slackSetupAdapter, }; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 2980316a138..3dfb195be86 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,9 +1,10 @@ +import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, - collectOpenProviderGroupPolicyWarnings, collectOpenGroupPolicyConfiguredRouteWarnings, -} from "openclaw/plugin-sdk/compat"; + collectOpenProviderGroupPolicyWarnings, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildAgentSessionKey, resolveThreadSessionKeys, @@ -11,7 +12,9 @@ import { } from "openclaw/plugin-sdk/core"; import { buildComputedAccountStatusSnapshot, + buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, + getChatChannelMeta, listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, looksLikeSlackTargetId, @@ -21,12 +24,10 @@ import { resolveConfiguredFromRequiredCredentialStatuses, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, + SlackConfigSchema, type ChannelPlugin, type OpenClawConfig, } from "openclaw/plugin-sdk/slack"; -import { createSlackActions } from "../../../src/channels/plugins/slack.actions.js"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; -import { normalizeOutboundThreadId } from "../../../src/infra/outbound/thread-id.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listEnabledSlackAccounts, @@ -37,26 +38,26 @@ import { import { parseSlackBlocksInput } from "./blocks-input.js"; import { createSlackWebClient } from "./client.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; +import { handleSlackMessageAction } from "./message-action-dispatch.js"; +import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; +import { + isSlackPluginAccountConfigured, + slackConfigAccessors, + slackConfigBase, + slackSetupWizard, +} from "./plugin-shared.js"; import type { SlackProbe } from "./probe.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { getSlackRuntime } from "./runtime.js"; import { fetchSlackScopes } from "./scopes.js"; -import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; -import { - createSlackPluginBase, - isSlackPluginAccountConfigured, - slackConfigAccessors, -} from "./shared.js"; +import { slackSetupAdapter } from "./setup-core.js"; import { parseSlackTarget } from "./targets.js"; import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; +const meta = getChatChannelMeta("slack"); const SLACK_CHANNEL_TYPE_CACHE = new Map(); -async function loadSlackChannelRuntime() { - return await import("./channel.runtime.js"); -} - // Select the appropriate Slack token for read/write operations. function getTokenForOperation( account: ResolvedSlackAccount, @@ -136,6 +137,20 @@ function parseSlackExplicitTarget(raw: string) { }; } +function normalizeOutboundThreadId(value?: string | number | null): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return undefined; + } + return String(Math.trunc(value)); + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + function buildSlackBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; @@ -314,21 +329,13 @@ async function resolveSlackAllowlistNames(params: { return await resolveSlackUserAllowlist({ token, entries: params.entries }); } -const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ - slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, -})); - -const slackActions = createSlackActions("slack", { - invoke: () => async (action, cfg, toolContext) => - await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), - skipNormalizeChannelId: true, -}); - export const slackPlugin: ChannelPlugin = { - ...createSlackPluginBase({ - setupWizard: slackSetupWizard, - setup: slackSetupAdapter, - }), + id: "slack", + meta: { + ...meta, + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: slackSetupWizard, pairing: { idLabel: "slackUserId", normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), @@ -357,6 +364,42 @@ export const slackPlugin: ChannelPlugin = { } }, }, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + agentPrompt: { + messageToolHints: ({ cfg, accountId }) => + isSlackInteractiveRepliesEnabled({ cfg, accountId }) + ? [ + "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", + "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", + ] + : [ + "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", + ], + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.slack"] }, + configSchema: buildChannelConfigSchema(SlackConfigSchema), + config: { + ...slackConfigBase, + isConfigured: (account) => isSlackPluginAccountConfigured(account), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: isSlackPluginAccountConfigured(account), + botTokenSource: account.botTokenSource, + appTokenSource: account.appTokenSource, + }), + ...slackConfigAccessors, + }, allowlist: { supportsScope: ({ scope }) => scope === "dm", readConfig: ({ cfg, accountId }) => @@ -511,7 +554,29 @@ export const slackPlugin: ChannelPlugin = { return resolved.map((entry) => toResolvedTarget(entry, entry.note)); }, }, - actions: slackActions, + actions: { + listActions: ({ cfg }) => listSlackMessageActions(cfg), + getCapabilities: ({ cfg }) => { + const capabilities = new Set<"interactive" | "blocks">(); + if (listSlackMessageActions(cfg).includes("send")) { + capabilities.add("blocks"); + } + if (isSlackInteractiveRepliesEnabled({ cfg })) { + capabilities.add("interactive"); + } + return Array.from(capabilities); + }, + extractToolSend: ({ args }) => extractSlackToolSend(args), + handleAction: async (ctx) => + await handleSlackMessageAction({ + providerId: meta.id, + ctx, + includeReadThreadId: true, + invoke: async (action, cfg, toolContext) => + await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), + }), + }, + setup: slackSetupAdapter, outbound: { deliveryMode: "direct", chunker: null, diff --git a/extensions/slack/src/directory-live.ts b/extensions/slack/src/directory-live.ts index 225548c646d..0a8bd04af22 100644 --- a/extensions/slack/src/directory-live.ts +++ b/extensions/slack/src/directory-live.ts @@ -1,5 +1,5 @@ -import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; -import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js"; +import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/channel-runtime"; import { resolveSlackAccount } from "./accounts.js"; import { createSlackWebClient } from "./client.js"; diff --git a/extensions/slack/src/draft-stream.ts b/extensions/slack/src/draft-stream.ts index bb80ff8d536..f122e2664c5 100644 --- a/extensions/slack/src/draft-stream.ts +++ b/extensions/slack/src/draft-stream.ts @@ -1,4 +1,4 @@ -import { createDraftStreamLoop } from "../../../src/channels/draft-stream-loop.js"; +import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-runtime"; import { deleteSlackMessage, editSlackMessage } from "./actions.js"; import { sendMessageSlack } from "./send.js"; diff --git a/extensions/slack/src/format.ts b/extensions/slack/src/format.ts index 69aeaa6b3b9..e5ab385fc6b 100644 --- a/extensions/slack/src/format.ts +++ b/extensions/slack/src/format.ts @@ -1,6 +1,10 @@ -import type { MarkdownTableMode } from "../../../src/config/types.base.js"; -import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../../../src/markdown/ir.js"; -import { renderMarkdownWithMarkers } from "../../../src/markdown/render.js"; +import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { + chunkMarkdownIR, + markdownToIR, + type MarkdownLinkSpan, +} from "openclaw/plugin-sdk/text-runtime"; +import { renderMarkdownWithMarkers } from "openclaw/plugin-sdk/text-runtime"; // Escape special characters for Slack mrkdwn format. // Preserve Slack's angle-bracket tokens so mentions and links stay intact. diff --git a/extensions/slack/src/interactive-replies.ts b/extensions/slack/src/interactive-replies.ts index 31784bd3b40..2a9703872c4 100644 --- a/extensions/slack/src/interactive-replies.ts +++ b/extensions/slack/src/interactive-replies.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { listSlackAccountIds, resolveSlackAccount } from "./accounts.js"; function resolveInteractiveRepliesFromCapabilities(capabilities: unknown): boolean { diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 486acfd4b2b..a589d28fed7 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1 +1 @@ -export { handleSlackMessageAction } from "../../../src/plugin-sdk-internal/slack.js"; +export { handleSlackMessageAction } from "openclaw/plugin-sdk/slack"; diff --git a/extensions/slack/src/message-actions.ts b/extensions/slack/src/message-actions.ts index 8e2a293f166..938659c9354 100644 --- a/extensions/slack/src/message-actions.ts +++ b/extensions/slack/src/message-actions.ts @@ -1,9 +1,9 @@ -import { createActionGate } from "../../../src/agents/tools/common.js"; +import { createActionGate } from "openclaw/plugin-sdk/agent-runtime"; import type { ChannelMessageActionName, ChannelToolSend, -} from "../../../src/channels/plugins/types.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { listEnabledSlackAccounts } from "./accounts.js"; export function listSlackMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { diff --git a/extensions/slack/src/monitor.test-helpers.ts b/extensions/slack/src/monitor.test-helpers.ts index c62147dd4a4..08cf5810345 100644 --- a/extensions/slack/src/monitor.test-helpers.ts +++ b/extensions/slack/src/monitor.test-helpers.ts @@ -187,15 +187,15 @@ export function resetSlackTestState(config: Record = defaultSla getSlackHandlers()?.clear(); } -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => slackTestState.config, }; }); -vi.mock("../../../src/auto-reply/reply.js", () => ({ +vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ getReplyFromConfig: (...args: unknown[]) => slackTestState.replyMock(...args), })); @@ -213,14 +213,14 @@ vi.mock("./send.js", () => ({ sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args), })); -vi.mock("../../../src/pairing/pairing-store.js", () => ({ +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ readChannelAllowFromStore: (...args: unknown[]) => slackTestState.readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => slackTestState.upsertPairingRequestMock(...args), })); -vi.mock("../../../src/config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), diff --git a/extensions/slack/src/monitor/allow-list.ts b/extensions/slack/src/monitor/allow-list.ts index 0e800047502..32fb7f40530 100644 --- a/extensions/slack/src/monitor/allow-list.ts +++ b/extensions/slack/src/monitor/allow-list.ts @@ -2,12 +2,12 @@ import { compileAllowlist, resolveCompiledAllowlistMatch, type AllowlistMatch, -} from "../../../../src/channels/allowlist-match.js"; +} from "openclaw/plugin-sdk/channel-runtime"; import { normalizeHyphenSlug, normalizeStringEntries, normalizeStringEntriesLower, -} from "../../../../src/shared/string-normalization.js"; +} from "openclaw/plugin-sdk/text-runtime"; const SLACK_SLUG_CACHE_MAX = 512; const slackSlugCache = new Map(); diff --git a/extensions/slack/src/monitor/auth.ts b/extensions/slack/src/monitor/auth.ts index 5022a94ad18..df8946a01c0 100644 --- a/extensions/slack/src/monitor/auth.ts +++ b/extensions/slack/src/monitor/auth.ts @@ -1,4 +1,4 @@ -import { readStoreAllowFromForDmPolicy } from "../../../../src/security/dm-policy-shared.js"; +import { readStoreAllowFromForDmPolicy } from "openclaw/plugin-sdk/security-runtime"; import { allowListMatches, normalizeAllowList, diff --git a/extensions/slack/src/monitor/channel-config.ts b/extensions/slack/src/monitor/channel-config.ts index e5f380a7102..32ad0e6f022 100644 --- a/extensions/slack/src/monitor/channel-config.ts +++ b/extensions/slack/src/monitor/channel-config.ts @@ -3,8 +3,8 @@ import { buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, type ChannelMatchSource, -} from "../../../../src/channels/channel-config.js"; -import type { SlackReactionNotificationMode } from "../../../../src/config/config.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { SlackReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime"; import type { SlackMessageEvent } from "../types.js"; import { allowListMatches, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js"; diff --git a/extensions/slack/src/monitor/commands.ts b/extensions/slack/src/monitor/commands.ts index 25fbaeb1007..1d83d9f74d1 100644 --- a/extensions/slack/src/monitor/commands.ts +++ b/extensions/slack/src/monitor/commands.ts @@ -1,4 +1,4 @@ -import type { SlackSlashCommandConfig } from "../../../../src/config/config.js"; +import type { SlackSlashCommandConfig } from "openclaw/plugin-sdk/config-runtime"; /** * Strip Slack mentions (<@U123>, <@U123|name>) so command detection works on diff --git a/extensions/slack/src/monitor/context.ts b/extensions/slack/src/monitor/context.ts index ad485a5c202..f39a92ce207 100644 --- a/extensions/slack/src/monitor/context.ts +++ b/extensions/slack/src/monitor/context.ts @@ -1,17 +1,17 @@ import type { App } from "@slack/bolt"; -import type { HistoryEntry } from "../../../../src/auto-reply/reply/history.js"; -import { formatAllowlistMatchMeta } from "../../../../src/channels/allowlist-match.js"; +import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig, SlackReactionNotificationMode, -} from "../../../../src/config/config.js"; -import { resolveSessionKey, type SessionScope } from "../../../../src/config/sessions.js"; -import type { DmPolicy, GroupPolicy } from "../../../../src/config/types.js"; -import { logVerbose } from "../../../../src/globals.js"; -import { createDedupeCache } from "../../../../src/infra/dedupe.js"; -import { getChildLogger } from "../../../../src/logging.js"; -import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { resolveSessionKey, type SessionScope } from "openclaw/plugin-sdk/config-runtime"; +import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/config-runtime"; +import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { SlackMessageEvent } from "../types.js"; import { normalizeAllowList, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js"; import type { SlackChannelConfigEntries } from "./channel-config.js"; @@ -53,7 +53,7 @@ export type SlackMonitorContext = { replyToMode: "off" | "first" | "all"; threadHistoryScope: "thread" | "channel"; threadInheritParent: boolean; - slashCommand: Required; + slashCommand: Required; textLimit: number; ackReactionScope: string; typingReaction: string; diff --git a/extensions/slack/src/monitor/dm-auth.ts b/extensions/slack/src/monitor/dm-auth.ts index 20d850d869a..930d31efdc5 100644 --- a/extensions/slack/src/monitor/dm-auth.ts +++ b/extensions/slack/src/monitor/dm-auth.ts @@ -1,6 +1,6 @@ -import { formatAllowlistMatchMeta } from "../../../../src/channels/allowlist-match.js"; -import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; +import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime"; +import { issuePairingChallenge } from "openclaw/plugin-sdk/conversation-runtime"; +import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { resolveSlackAllowListMatch } from "./allow-list.js"; import type { SlackMonitorContext } from "./context.js"; diff --git a/extensions/slack/src/monitor/events/channels.ts b/extensions/slack/src/monitor/events/channels.ts index 283b6648cf9..e4940f80d9f 100644 --- a/extensions/slack/src/monitor/events/channels.ts +++ b/extensions/slack/src/monitor/events/channels.ts @@ -1,8 +1,8 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { resolveChannelConfigWrites } from "../../../../../src/channels/plugins/config-writes.js"; -import { loadConfig, writeConfigFile } from "../../../../../src/config/config.js"; -import { danger, warn } from "../../../../../src/globals.js"; -import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; +import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-runtime"; +import { loadConfig, writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { danger, warn } from "openclaw/plugin-sdk/runtime-env"; import { migrateSlackChannelConfig } from "../../channel-migration.js"; import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/extensions/slack/src/monitor/events/interactions.block-actions.ts b/extensions/slack/src/monitor/events/interactions.block-actions.ts index 1f54df45a5d..f8a18720933 100644 --- a/extensions/slack/src/monitor/events/interactions.block-actions.ts +++ b/extensions/slack/src/monitor/events/interactions.block-actions.ts @@ -1,12 +1,12 @@ import type { SlackActionMiddlewareArgs } from "@slack/bolt"; import type { Block, KnownBlock } from "@slack/web-api"; -import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; import { buildPluginBindingResolvedText, parsePluginBindingApprovalCustomId, resolvePluginConversationBindingApproval, -} from "../../../../../src/plugins/conversation-binding.js"; -import { dispatchPluginInteractiveHandler } from "../../../../../src/plugins/interactive.js"; +} from "openclaw/plugin-sdk/conversation-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime"; import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID } from "../../blocks-render.js"; import { authorizeSlackSystemEventSender } from "../auth.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/extensions/slack/src/monitor/events/interactions.modal.ts b/extensions/slack/src/monitor/events/interactions.modal.ts index 48e163c317f..14f7a0af0cd 100644 --- a/extensions/slack/src/monitor/events/interactions.modal.ts +++ b/extensions/slack/src/monitor/events/interactions.modal.ts @@ -1,4 +1,4 @@ -import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js"; import { authorizeSlackSystemEventSender } from "../auth.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/extensions/slack/src/monitor/events/members.ts b/extensions/slack/src/monitor/events/members.ts index 490c0bf6f04..26d02f11613 100644 --- a/extensions/slack/src/monitor/events/members.ts +++ b/extensions/slack/src/monitor/events/members.ts @@ -1,6 +1,6 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../../../src/globals.js"; -import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { SlackMonitorContext } from "../context.js"; import type { SlackMemberChannelEvent } from "../types.js"; import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js"; diff --git a/extensions/slack/src/monitor/events/messages.ts b/extensions/slack/src/monitor/events/messages.ts index b950d5d19ea..309308caa57 100644 --- a/extensions/slack/src/monitor/events/messages.ts +++ b/extensions/slack/src/monitor/events/messages.ts @@ -1,6 +1,6 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../../../src/globals.js"; -import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { SlackAppMentionEvent, SlackMessageEvent } from "../../types.js"; import { normalizeSlackChannelType } from "../channel-type.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/extensions/slack/src/monitor/events/pins.ts b/extensions/slack/src/monitor/events/pins.ts index f051270624c..ba95f515810 100644 --- a/extensions/slack/src/monitor/events/pins.ts +++ b/extensions/slack/src/monitor/events/pins.ts @@ -1,6 +1,6 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../../../src/globals.js"; -import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { SlackMonitorContext } from "../context.js"; import type { SlackPinEvent } from "../types.js"; import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js"; diff --git a/extensions/slack/src/monitor/events/reactions.ts b/extensions/slack/src/monitor/events/reactions.ts index 439c15e6d12..f439168dfde 100644 --- a/extensions/slack/src/monitor/events/reactions.ts +++ b/extensions/slack/src/monitor/events/reactions.ts @@ -1,6 +1,6 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../../../src/globals.js"; -import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { SlackMonitorContext } from "../context.js"; import type { SlackReactionEvent } from "../types.js"; import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js"; diff --git a/extensions/slack/src/monitor/events/system-event-context.ts b/extensions/slack/src/monitor/events/system-event-context.ts index 278dd2324d7..544a889df5f 100644 --- a/extensions/slack/src/monitor/events/system-event-context.ts +++ b/extensions/slack/src/monitor/events/system-event-context.ts @@ -1,4 +1,4 @@ -import { logVerbose } from "../../../../../src/globals.js"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { authorizeSlackSystemEventSender } from "../auth.js"; import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/extensions/slack/src/monitor/external-arg-menu-store.ts b/extensions/slack/src/monitor/external-arg-menu-store.ts index e2cbf68479d..c3327ee88c6 100644 --- a/extensions/slack/src/monitor/external-arg-menu-store.ts +++ b/extensions/slack/src/monitor/external-arg-menu-store.ts @@ -1,4 +1,4 @@ -import { generateSecureToken } from "../../../../src/infra/secure-random.js"; +import { generateSecureToken } from "openclaw/plugin-sdk/infra-runtime"; const SLACK_EXTERNAL_ARG_MENU_TOKEN_BYTES = 18; const SLACK_EXTERNAL_ARG_MENU_TOKEN_LENGTH = Math.ceil( diff --git a/extensions/slack/src/monitor/media.ts b/extensions/slack/src/monitor/media.ts index ef494f2e48c..ef574a7381c 100644 --- a/extensions/slack/src/monitor/media.ts +++ b/extensions/slack/src/monitor/media.ts @@ -1,9 +1,9 @@ import type { WebClient as SlackWebClient } from "@slack/web-api"; +import { normalizeHostname } from "openclaw/plugin-sdk/infra-runtime"; +import type { FetchLike } from "openclaw/plugin-sdk/media-runtime"; +import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; +import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url"; -import { normalizeHostname } from "../../../../src/infra/net/hostname.js"; -import type { FetchLike } from "../../../../src/media/fetch.js"; -import { fetchRemoteMedia } from "../../../../src/media/fetch.js"; -import { saveMediaBuffer } from "../../../../src/media/store.js"; import type { SlackAttachment, SlackFile } from "../types.js"; function isSlackHostname(hostname: string): boolean { diff --git a/extensions/slack/src/monitor/message-handler.ts b/extensions/slack/src/monitor/message-handler.ts index 37e0eb23bd3..feaddff98df 100644 --- a/extensions/slack/src/monitor/message-handler.ts +++ b/extensions/slack/src/monitor/message-handler.ts @@ -1,7 +1,7 @@ import { createChannelInboundDebouncer, shouldDebounceTextInbound, -} from "../../../../src/channels/inbound-debounce-policy.js"; +} from "openclaw/plugin-sdk/channel-runtime"; import type { ResolvedSlackAccount } from "../accounts.js"; import type { SlackMessageEvent } from "../types.js"; import { stripSlackMentionsForCommandDetection } from "./commands.js"; diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 43ee958bdda..569ca8f60a7 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -1,16 +1,16 @@ -import { resolveHumanDelayConfig } from "../../../../../src/agents/identity.js"; -import { dispatchInboundMessage } from "../../../../../src/auto-reply/dispatch.js"; -import { clearHistoryEntriesIfEnabled } from "../../../../../src/auto-reply/reply/history.js"; -import { createReplyDispatcherWithTyping } from "../../../../../src/auto-reply/reply/reply-dispatcher.js"; -import type { ReplyPayload } from "../../../../../src/auto-reply/types.js"; -import { removeAckReactionAfterReply } from "../../../../../src/channels/ack-reactions.js"; -import { logAckFailure, logTypingFailure } from "../../../../../src/channels/logging.js"; -import { createReplyPrefixOptions } from "../../../../../src/channels/reply-prefix.js"; -import { createTypingCallbacks } from "../../../../../src/channels/typing.js"; -import { resolveStorePath, updateLastRoute } from "../../../../../src/config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose } from "../../../../../src/globals.js"; -import { resolveAgentOutboundIdentity } from "../../../../../src/infra/outbound/identity.js"; -import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../../src/security/dm-policy-shared.js"; +import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { removeAckReactionAfterReply } from "openclaw/plugin-sdk/channel-runtime"; +import { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime"; +import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; +import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/config-runtime"; +import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; +import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; +import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime"; +import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; import { editSlackMessage, reactSlackMessage, removeSlackReaction } from "../../actions.js"; import { createSlackDraftStream } from "../../draft-stream.js"; import { normalizeSlackOutboundText } from "../../format.js"; diff --git a/extensions/slack/src/monitor/message-handler/prepare-content.ts b/extensions/slack/src/monitor/message-handler/prepare-content.ts index e1db426ad7e..54a5183bfb0 100644 --- a/extensions/slack/src/monitor/message-handler/prepare-content.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-content.ts @@ -1,4 +1,4 @@ -import { logVerbose } from "../../../../../src/globals.js"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { SlackFile, SlackMessageEvent } from "../../types.js"; import { MAX_SLACK_MEDIA_FILES, diff --git a/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts b/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts index 9673e8d72cc..5d4020f1b46 100644 --- a/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts @@ -1,6 +1,6 @@ -import { formatInboundEnvelope } from "../../../../../src/auto-reply/envelope.js"; -import { readSessionUpdatedAt } from "../../../../../src/config/sessions.js"; -import { logVerbose } from "../../../../../src/globals.js"; +import { readSessionUpdatedAt } from "openclaw/plugin-sdk/config-runtime"; +import { formatInboundEnvelope } from "openclaw/plugin-sdk/reply-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackMonitorContext } from "../context.js"; @@ -30,7 +30,7 @@ export async function resolveSlackThreadContextData(params: { storePath: string; sessionKey: string; envelopeOptions: ReturnType< - typeof import("../../../../../src/auto-reply/envelope.js").resolveEnvelopeFormatOptions + typeof import("openclaw/plugin-sdk/reply-runtime").resolveEnvelopeFormatOptions >; effectiveDirectMedia: SlackMediaResult[] | null; }): Promise { diff --git a/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts b/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts index cdc7a3bc411..f6d3ab21ce9 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts @@ -1,6 +1,6 @@ import type { App } from "@slack/bolt"; -import type { OpenClawConfig } from "../../../../../src/config/config.js"; -import type { RuntimeEnv } from "../../../../../src/runtime.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { ResolvedSlackAccount } from "../../accounts.js"; import { createSlackMonitorContext } from "../context.js"; diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index ba18b008d37..e6bc3a23446 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -1,35 +1,32 @@ -import { resolveAckReaction } from "../../../../../src/agents/identity.js"; -import { hasControlCommand } from "../../../../../src/auto-reply/command-detection.js"; -import { shouldHandleTextCommands } from "../../../../../src/auto-reply/commands-registry.js"; -import { - formatInboundEnvelope, - resolveEnvelopeFormatOptions, -} from "../../../../../src/auto-reply/envelope.js"; -import { - buildPendingHistoryContextFromMap, - recordPendingHistoryEntryIfEnabled, -} from "../../../../../src/auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../../../../../src/auto-reply/reply/inbound-context.js"; -import { - buildMentionRegexes, - matchesMentionWithExplicit, -} from "../../../../../src/auto-reply/reply/mentions.js"; -import type { FinalizedMsgContext } from "../../../../../src/auto-reply/templating.js"; +import { resolveAckReaction } from "openclaw/plugin-sdk/agent-runtime"; import { shouldAckReaction as shouldAckReactionGate, type AckReactionScope, -} from "../../../../../src/channels/ack-reactions.js"; -import { resolveControlCommandGate } from "../../../../../src/channels/command-gating.js"; -import { resolveConversationLabel } from "../../../../../src/channels/conversation-label.js"; -import { logInboundDrop } from "../../../../../src/channels/logging.js"; -import { resolveMentionGatingWithBypass } from "../../../../../src/channels/mention-gating.js"; -import { recordInboundSession } from "../../../../../src/channels/session.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../../../../src/config/sessions.js"; -import { logVerbose, shouldLogVerbose } from "../../../../../src/globals.js"; -import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; -import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; -import { resolveThreadSessionKeys } from "../../../../../src/routing/session-key.js"; -import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../../src/security/dm-policy-shared.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveConversationLabel } from "openclaw/plugin-sdk/channel-runtime"; +import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; +import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; +import { shouldHandleTextCommands } from "openclaw/plugin-sdk/reply-runtime"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "openclaw/plugin-sdk/reply-runtime"; +import { + buildPendingHistoryContextFromMap, + recordPendingHistoryEntryIfEnabled, +} from "openclaw/plugin-sdk/reply-runtime"; +import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; +import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime"; +import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; import { resolveSlackReplyToMode, type ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; import { sendMessageSlack } from "../../send.js"; diff --git a/extensions/slack/src/monitor/message-handler/types.ts b/extensions/slack/src/monitor/message-handler/types.ts index cd1e2bdc40c..bcff64cc470 100644 --- a/extensions/slack/src/monitor/message-handler/types.ts +++ b/extensions/slack/src/monitor/message-handler/types.ts @@ -1,5 +1,5 @@ -import type { FinalizedMsgContext } from "../../../../../src/auto-reply/templating.js"; -import type { ResolvedAgentRoute } from "../../../../../src/routing/resolve-route.js"; +import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime"; +import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackChannelConfigResolved } from "../channel-config.js"; diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 2104a5355cf..5a382551b47 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -1,30 +1,30 @@ import type { IncomingMessage, ServerResponse } from "node:http"; 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 { addAllowlistUserEntriesFromConfigEntry, buildAllowlistResolutionSummary, mergeAllowlist, patchAllowlistUsersInConfigEntries, summarizeMapping, -} from "../../../../src/channels/allowlists/resolve-utils.js"; -import { loadConfig } from "../../../../src/config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, -} from "../../../../src/config/runtime-group-policy.js"; -import type { SessionScope } from "../../../../src/config/sessions.js"; -import { normalizeResolvedSecretInputString } from "../../../../src/config/types.secrets.js"; -import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js"; -import { warn } from "../../../../src/globals.js"; -import { computeBackoff, sleepWithAbort } from "../../../../src/infra/backoff.js"; -import { installRequestBodyLimitGuard } from "../../../../src/infra/http-body.js"; -import { normalizeMainKey } from "../../../../src/routing/session-key.js"; -import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; -import { normalizeStringEntries } from "../../../../src/shared/string-normalization.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import type { SessionScope } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; +import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; +import { computeBackoff, sleepWithAbort } from "openclaw/plugin-sdk/infra-runtime"; +import { installRequestBodyLimitGuard } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; +import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-runtime"; +import { normalizeMainKey } from "openclaw/plugin-sdk/routing"; +import { warn } from "openclaw/plugin-sdk/runtime-env"; +import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { resolveSlackAccount } from "../accounts.js"; import { resolveSlackWebClientOptions } from "../client.js"; import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js"; diff --git a/extensions/slack/src/monitor/replies.ts b/extensions/slack/src/monitor/replies.ts index 885e71b7818..a8ef26510f0 100644 --- a/extensions/slack/src/monitor/replies.ts +++ b/extensions/slack/src/monitor/replies.ts @@ -1,10 +1,10 @@ -import type { ChunkMode } from "../../../../src/auto-reply/chunk.js"; -import { chunkMarkdownTextWithMode } from "../../../../src/auto-reply/chunk.js"; -import { createReplyReferencePlanner } from "../../../../src/auto-reply/reply/reply-reference.js"; -import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../../src/auto-reply/tokens.js"; -import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; -import type { MarkdownTableMode } from "../../../../src/config/types.base.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; +import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; +import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-runtime"; +import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { parseSlackBlocksInput } from "../blocks-input.js"; import { markdownToSlackMrkdwnChunks } from "../format.js"; import { sendMessageSlack, type SlackSendIdentity } from "../send.js"; diff --git a/extensions/slack/src/monitor/room-context.ts b/extensions/slack/src/monitor/room-context.ts index 3cdf584566a..955f9f3c855 100644 --- a/extensions/slack/src/monitor/room-context.ts +++ b/extensions/slack/src/monitor/room-context.ts @@ -1,4 +1,4 @@ -import { buildUntrustedChannelMetadata } from "../../../../src/security/channel-metadata.js"; +import { buildUntrustedChannelMetadata } from "openclaw/plugin-sdk/security-runtime"; export function resolveSlackRoomContextHints(params: { isRoomish: boolean; diff --git a/extensions/slack/src/monitor/slash-commands.runtime.ts b/extensions/slack/src/monitor/slash-commands.runtime.ts index a87490f43bc..63fa59cd347 100644 --- a/extensions/slack/src/monitor/slash-commands.runtime.ts +++ b/extensions/slack/src/monitor/slash-commands.runtime.ts @@ -4,4 +4,4 @@ export { listNativeCommandSpecsForConfig, parseCommandArgs, resolveCommandArgMenu, -} from "../../../../src/auto-reply/commands-registry.js"; +} from "openclaw/plugin-sdk/reply-runtime"; diff --git a/extensions/slack/src/monitor/slash-dispatch.runtime.ts b/extensions/slack/src/monitor/slash-dispatch.runtime.ts index 01e47782467..0095471359c 100644 --- a/extensions/slack/src/monitor/slash-dispatch.runtime.ts +++ b/extensions/slack/src/monitor/slash-dispatch.runtime.ts @@ -1,9 +1,9 @@ -export { resolveChunkMode } from "../../../../src/auto-reply/chunk.js"; -export { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; -export { dispatchReplyWithDispatcher } from "../../../../src/auto-reply/reply/provider-dispatcher.js"; -export { resolveConversationLabel } from "../../../../src/channels/conversation-label.js"; -export { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; -export { recordInboundSessionMetaSafe } from "../../../../src/channels/session-meta.js"; -export { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js"; -export { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +export { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; +export { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; +export { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-runtime"; +export { resolveConversationLabel } from "openclaw/plugin-sdk/channel-runtime"; +export { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; +export { recordInboundSessionMetaSafe } from "openclaw/plugin-sdk/channel-runtime"; +export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +export { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; export { deliverSlackSlashReplies } from "./replies.js"; diff --git a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts index 20da07b3ec5..738580cdc0f 100644 --- a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts +++ b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts @@ -1 +1 @@ -export { listSkillCommandsForAgents } from "../../../../src/auto-reply/skill-commands.js"; +export { listSkillCommandsForAgents } from "openclaw/plugin-sdk/reply-runtime"; diff --git a/extensions/slack/src/monitor/slash.test-harness.ts b/extensions/slack/src/monitor/slash.test-harness.ts index 4b6f5a4ea27..3172154739e 100644 --- a/extensions/slack/src/monitor/slash.test-harness.ts +++ b/extensions/slack/src/monitor/slash.test-harness.ts @@ -12,32 +12,32 @@ const mocks = vi.hoisted(() => ({ resolveStorePathMock: vi.fn(), })); -vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ +vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ dispatchReplyWithDispatcher: (...args: unknown[]) => mocks.dispatchMock(...args), })); -vi.mock("../../../../src/pairing/pairing-store.js", () => ({ +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ readChannelAllowFromStore: (...args: unknown[]) => mocks.readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => mocks.upsertPairingRequestMock(...args), })); -vi.mock("../../../../src/routing/resolve-route.js", () => ({ +vi.mock("openclaw/plugin-sdk/routing", () => ({ resolveAgentRoute: (...args: unknown[]) => mocks.resolveAgentRouteMock(...args), })); -vi.mock("../../../../src/auto-reply/reply/inbound-context.js", () => ({ +vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ finalizeInboundContext: (...args: unknown[]) => mocks.finalizeInboundContextMock(...args), })); -vi.mock("../../../../src/channels/conversation-label.js", () => ({ +vi.mock("openclaw/plugin-sdk/channel-runtime", () => ({ resolveConversationLabel: (...args: unknown[]) => mocks.resolveConversationLabelMock(...args), })); -vi.mock("../../../../src/channels/reply-prefix.js", () => ({ +vi.mock("openclaw/plugin-sdk/channel-runtime", () => ({ createReplyPrefixOptions: (...args: unknown[]) => mocks.createReplyPrefixOptionsMock(...args), })); -vi.mock("../../../../src/config/sessions.js", () => ({ +vi.mock("openclaw/plugin-sdk/config-runtime", () => ({ recordSessionMetaFromInbound: (...args: unknown[]) => mocks.recordSessionMetaFromInboundMock(...args), resolveStorePath: (...args: unknown[]) => mocks.resolveStorePathMock(...args), diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index adf173a0961..a1c0bfa13a4 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -1,17 +1,14 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt"; -import { - type ChatCommandDefinition, - type CommandArgs, -} from "../../../../src/auto-reply/commands-registry.js"; -import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; -import { resolveNativeCommandSessionTargets } from "../../../../src/channels/native-command-session-targets.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/channel-runtime"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, -} from "../../../../src/config/commands.js"; -import { danger, logVerbose } from "../../../../src/globals.js"; -import { chunkItems } from "../../../../src/utils/chunk-items.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { type ChatCommandDefinition, type CommandArgs } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { chunkItems } from "openclaw/plugin-sdk/text-runtime"; import type { ResolvedSlackAccount } from "../accounts.js"; import { truncateSlackText } from "../truncate.js"; import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "./allow-list.js"; diff --git a/extensions/slack/src/monitor/thread-resolution.ts b/extensions/slack/src/monitor/thread-resolution.ts index 4230d5fc50f..11d54cd1ea6 100644 --- a/extensions/slack/src/monitor/thread-resolution.ts +++ b/extensions/slack/src/monitor/thread-resolution.ts @@ -1,6 +1,6 @@ import type { WebClient as SlackWebClient } from "@slack/web-api"; -import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; -import { pruneMapToMaxSize } from "../../../../src/infra/map-size.js"; +import { pruneMapToMaxSize } from "openclaw/plugin-sdk/infra-runtime"; +import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { SlackMessageEvent } from "../types.js"; type ThreadTsCacheEntry = { diff --git a/extensions/slack/src/monitor/types.ts b/extensions/slack/src/monitor/types.ts index 1239ab771f5..5543697dcfa 100644 --- a/extensions/slack/src/monitor/types.ts +++ b/extensions/slack/src/monitor/types.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig, SlackSlashCommandConfig } from "../../../../src/config/config.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; +import type { OpenClawConfig, SlackSlashCommandConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { SlackFile, SlackMessageEvent } from "../types.js"; export type MonitorSlackOpts = { diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index 1c851c8f69e..56a5c995e40 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -2,15 +2,15 @@ import { resolvePayloadMediaUrls, sendPayloadMediaSequence, sendTextMediaPayload, -} from "../../../src/channels/plugins/outbound/direct-text-media.js"; -import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; -import type { OutboundIdentity } from "../../../src/infra/outbound/identity.js"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { resolveInteractiveTextFallback, type InteractiveReply, -} from "../../../src/interactive/payload.js"; -import { getGlobalHookRunner } from "../../../src/plugins/hook-runner-global.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; +import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js"; import { sendMessageSlack, type SlackSendIdentity } from "./send.js"; diff --git a/extensions/slack/src/plugin-shared.ts b/extensions/slack/src/plugin-shared.ts index 0c5a6c7957e..0a5eb6ea3ec 100644 --- a/extensions/slack/src/plugin-shared.ts +++ b/extensions/slack/src/plugin-shared.ts @@ -1,9 +1,9 @@ +import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, - formatAllowFromLowercase, -} from "../../../src/plugin-sdk-internal/channel-config.js"; -import { type OpenClawConfig } from "../../../src/plugin-sdk-internal/slack.js"; +} from "openclaw/plugin-sdk/channel-config-helpers"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/slack"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, diff --git a/extensions/slack/src/probe.ts b/extensions/slack/src/probe.ts index dba8744a18c..c370b11be9b 100644 --- a/extensions/slack/src/probe.ts +++ b/extensions/slack/src/probe.ts @@ -1,5 +1,5 @@ -import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; -import { withTimeout } from "../../../src/utils/with-timeout.js"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import { withTimeout } from "openclaw/plugin-sdk/text-runtime"; import { createSlackWebClient } from "./client.js"; export type SlackProbe = BaseProbeResult & { diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts index d7d09dbcb6b..2121ee9f902 100644 --- a/extensions/slack/src/runtime.ts +++ b/extensions/slack/src/runtime.ts @@ -1,7 +1,5 @@ -import { - createPluginRuntimeStore, - type PluginRuntime, -} from "../../../src/plugin-sdk-internal/core.js"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } = createPluginRuntimeStore("Slack runtime not initialized"); diff --git a/extensions/slack/src/scopes.ts b/extensions/slack/src/scopes.ts index e0fe58161f3..fc7e14d741b 100644 --- a/extensions/slack/src/scopes.ts +++ b/extensions/slack/src/scopes.ts @@ -1,5 +1,5 @@ import type { WebClient } from "@slack/web-api"; -import { isRecord } from "../../../src/utils.js"; +import { isRecord } from "openclaw/plugin-sdk/text-runtime"; import { createSlackWebClient } from "./client.js"; export type SlackScopesResult = { diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index 293affe0218..cc352284ca3 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -1,17 +1,17 @@ import { type Block, type KnownBlock, type WebClient } from "@slack/web-api"; +import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { + fetchWithSsrFGuard, + withTrustedEnvProxyGuardedFetchMode, +} from "openclaw/plugin-sdk/infra-runtime"; import { chunkMarkdownTextWithMode, resolveChunkMode, resolveTextChunkLimit, -} from "../../../src/auto-reply/chunk.js"; -import { isSilentReplyText } from "../../../src/auto-reply/tokens.js"; -import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js"; -import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; -import { logVerbose } from "../../../src/globals.js"; -import { - fetchWithSsrFGuard, - withTrustedEnvProxyGuardedFetchMode, -} from "../../../src/infra/net/fetch-guard.js"; +} from "openclaw/plugin-sdk/reply-runtime"; +import { isSilentReplyText } from "openclaw/plugin-sdk/reply-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { loadWebMedia } from "../../whatsapp/src/media.js"; import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; diff --git a/extensions/slack/src/sent-thread-cache.ts b/extensions/slack/src/sent-thread-cache.ts index 37cf8155472..f155571a1b4 100644 --- a/extensions/slack/src/sent-thread-cache.ts +++ b/extensions/slack/src/sent-thread-cache.ts @@ -1,4 +1,4 @@ -import { resolveGlobalMap } from "../../../src/shared/global-singleton.js"; +import { resolveGlobalMap } from "openclaw/plugin-sdk/text-runtime"; /** * In-memory cache of Slack threads the bot has participated in. diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index b53472c3ce9..2b3753a3c6d 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,7 +1,10 @@ -import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import { + applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, + formatDocsLink, hasConfiguredSecretInput, + migrateBaseNameToDefaultAccount, + normalizeAccountId, type OpenClawConfig, noteChannelLookupFailure, noteChannelLookupSummary, @@ -10,14 +13,13 @@ import { setAccountGroupPolicyForChannel, setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, -} from "../../../src/plugin-sdk-internal/setup.js"; +} from "openclaw/plugin-sdk/setup"; import { type ChannelSetupAdapter, type ChannelSetupDmPolicy, type ChannelSetupWizard, type ChannelSetupWizardAllowFromEntry, -} from "../../../src/plugin-sdk-internal/setup.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +} from "openclaw/plugin-sdk/setup"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; import { @@ -36,8 +38,15 @@ function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawCon }); } -export const slackSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ - channelKey: channel, +export const slackSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "Slack env tokens can only be used for the default account."; @@ -47,93 +56,63 @@ export const slackSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupA } return null; }, - buildPatch: (input) => - input.useEnv - ? {} - : { - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }, -}); - -type SlackAllowFromResolverParams = { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { botToken?: string }; - entries: string[]; -}; - -type SlackGroupAllowlistResolverParams = SlackAllowFromResolverParams & { - prompter: { note: (message: string, title?: string) => Promise }; -}; - -type SlackSetupWizardHandlers = { - promptAllowFrom: (params: { - cfg: OpenClawConfig; - prompter: import("../../../src/plugin-sdk-internal/setup.js").WizardPrompter; - accountId?: string; - }) => Promise; - resolveAllowFromEntries: ( - params: SlackAllowFromResolverParams, - ) => Promise; - resolveGroupAllowlist: (params: SlackGroupAllowlistResolverParams) => Promise; -}; - -function buildSlackTokenCredential(params: { - inputKey: "botToken" | "appToken"; - providerHint: "slack-bot" | "slack-app"; - credentialLabel: string; - preferredEnvVar: "SLACK_BOT_TOKEN" | "SLACK_APP_TOKEN"; - inputPrompt: string; -}): NonNullable[number] { - const configKey = params.inputKey; - return { - inputKey: params.inputKey, - providerHint: params.providerHint, - credentialLabel: params.credentialLabel, - preferredEnvVar: params.preferredEnvVar, - envPrompt: `${params.preferredEnvVar} detected. Use env var?`, - keepPrompt: `${params.credentialLabel} already configured. Keep it?`, - inputPrompt: params.inputPrompt, - allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { - const resolved = resolveSlackAccount({ cfg, accountId }); - const tokenValue = resolved[configKey]?.trim() || undefined; - const configuredValue = resolved.config[configKey]; - return { - accountConfigured: Boolean(tokenValue) || hasConfiguredSecretInput(configuredValue), - hasConfiguredValue: hasConfiguredSecretInput(configuredValue), - resolvedValue: tokenValue, - envValue: - accountId === DEFAULT_ACCOUNT_ID - ? process.env[params.preferredEnvVar]?.trim() - : undefined, - }; - }, - applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => - enableSlackAccount(cfg, accountId), - applySet: ({ + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ cfg, + channelKey: channel, accountId, - value, - }: { - cfg: OpenClawConfig; - accountId: string; - value: unknown; - }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { - enabled: true, - [configKey]: value, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + slack: { + ...next.channels?.slack, + enabled: true, + ...(input.useEnv + ? {} + : { + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }), + }, }, - }), - }; -} + }; + } + return { + ...next, + channels: { + ...next.channels, + slack: { + ...next.channels?.slack, + enabled: true, + accounts: { + ...next.channels?.slack?.accounts, + [accountId]: { + ...next.channels?.slack?.accounts?.[accountId], + enabled: true, + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }, + }, + }, + }, + }; + }, +}; -export function createSlackSetupWizardBase(handlers: SlackSetupWizardHandlers): ChannelSetupWizard { +export function createSlackSetupWizardProxy( + loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, +) { const slackDmPolicy: ChannelSetupDmPolicy = { label: "Slack", channel, @@ -147,7 +126,13 @@ export function createSlackSetupWizardBase(handlers: SlackSetupWizardHandlers): channel, dmPolicy: policy, }), - promptAllowFrom: handlers.promptAllowFrom, + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, }; return { @@ -182,20 +167,88 @@ export function createSlackSetupWizardBase(handlers: SlackSetupWizardHandlers): apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), }, credentials: [ - buildSlackTokenCredential({ + { inputKey: "botToken", providerHint: "slack-bot", credentialLabel: "Slack bot token", preferredEnvVar: "SLACK_BOT_TOKEN", + envPrompt: "SLACK_BOT_TOKEN detected. Use env var?", + keepPrompt: "Slack bot token already configured. Keep it?", inputPrompt: "Enter Slack bot token (xoxb-...)", - }), - buildSlackTokenCredential({ + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + return { + accountConfigured: + Boolean(resolved.botToken) || hasConfiguredSecretInput(resolved.config.botToken), + hasConfiguredValue: hasConfiguredSecretInput(resolved.config.botToken), + resolvedValue: resolved.botToken?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_BOT_TOKEN?.trim() : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + enableSlackAccount(cfg, accountId), + applySet: ({ + cfg, + accountId, + value, + }: { + cfg: OpenClawConfig; + accountId: string; + value: unknown; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { + enabled: true, + botToken: value, + }, + }), + }, + { inputKey: "appToken", providerHint: "slack-app", credentialLabel: "Slack app token", preferredEnvVar: "SLACK_APP_TOKEN", + envPrompt: "SLACK_APP_TOKEN detected. Use env var?", + keepPrompt: "Slack app token already configured. Keep it?", inputPrompt: "Enter Slack app token (xapp-...)", - }), + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + return { + accountConfigured: + Boolean(resolved.appToken) || hasConfiguredSecretInput(resolved.config.appToken), + hasConfiguredValue: hasConfiguredSecretInput(resolved.config.appToken), + resolvedValue: resolved.appToken?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_APP_TOKEN?.trim() : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + enableSlackAccount(cfg, accountId), + applySet: ({ + cfg, + accountId, + value, + }: { + cfg: OpenClawConfig; + accountId: string; + value: unknown; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { + enabled: true, + appToken: value, + }, + }), + }, ], dmPolicy: slackDmPolicy, allowFrom: { @@ -220,7 +273,28 @@ export function createSlackSetupWizardBase(handlers: SlackSetupWizardHandlers): idPattern: /^[A-Z][A-Z0-9]+$/i, normalizeId: (id) => id.toUpperCase(), }), - resolveEntries: handlers.resolveAllowFromEntries, + resolveEntries: async ({ + cfg, + accountId, + credentialValues, + entries, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { botToken?: string }; + entries: string[]; + }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, apply: ({ cfg, accountId, @@ -263,22 +337,44 @@ export function createSlackSetupWizardBase(handlers: SlackSetupWizardHandlers): accountId, groupPolicy: policy, }), - resolveAllowlist: async (params: SlackGroupAllowlistResolverParams) => { + resolveAllowlist: async ({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { botToken?: string }; + entries: string[]; + prompter: { note: (message: string, title?: string) => Promise }; + }) => { try { - return await handlers.resolveGroupAllowlist(params); + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.groupAccess?.resolveAllowlist) { + return entries; + } + return await wizard.groupAccess.resolveAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }); } catch (error) { await noteChannelLookupFailure({ - prompter: params.prompter, + prompter, label: "Slack channels", error, }); await noteChannelLookupSummary({ - prompter: params.prompter, + prompter, label: "Slack channels", resolvedSections: [], - unresolved: params.entries, + unresolved: entries, }); - return params.entries; + return entries; } }, applyAllowlist: ({ @@ -294,42 +390,3 @@ export function createSlackSetupWizardBase(handlers: SlackSetupWizardHandlers): disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } - -export function createSlackSetupWizardProxy( - loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, -) { - return createSlackSetupWizardBase({ - promptAllowFrom: async ({ cfg, prompter, accountId }) => { - const wizard = (await loadWizard()).slackSetupWizard; - if (!wizard.dmPolicy?.promptAllowFrom) { - return cfg; - } - return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); - }, - resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => { - const wizard = (await loadWizard()).slackSetupWizard; - if (!wizard.allowFrom) { - return entries.map((input) => ({ input, resolved: false, id: null })); - } - return await wizard.allowFrom.resolveEntries({ - cfg, - accountId, - credentialValues, - entries, - }); - }, - resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { - const wizard = (await loadWizard()).slackSetupWizard; - if (!wizard.groupAccess?.resolveAllowlist) { - return entries; - } - return (await wizard.groupAccess.resolveAllowlist({ - cfg, - accountId, - credentialValues, - entries, - prompter, - })) as string[]; - }, - }); -} diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index 8f5024276ca..1dbfa4f02ce 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,22 +1,50 @@ import { + DEFAULT_ACCOUNT_ID, formatDocsLink, + hasConfiguredSecretInput, noteChannelLookupFailure, noteChannelLookupSummary, + normalizeAccountId, type OpenClawConfig, parseMentionOrPrefixedId, + patchChannelConfigForAccount, promptLegacyChannelAllowFrom, resolveSetupAccountId, + setAccountGroupPolicyForChannel, + setLegacyChannelDmPolicyWithAllowFrom, + setSetupChannelEnabled, type WizardPrompter, -} from "../../../src/plugin-sdk-internal/setup.js"; +} from "openclaw/plugin-sdk/setup"; import type { + ChannelSetupDmPolicy, ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, -} from "../../../src/plugin-sdk-internal/setup.js"; -import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js"; +} from "openclaw/plugin-sdk/setup"; +import { inspectSlackAccount } from "./account-inspect.js"; +import { + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + type ResolvedSlackAccount, +} from "./accounts.js"; import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; -import { createSlackSetupWizardBase } from "./setup-core.js"; -import { SLACK_CHANNEL as channel } from "./shared.js"; +import { slackSetupAdapter } from "./setup-core.js"; +import { + buildSlackSetupLines, + isSlackSetupAccountConfigured, + setSlackChannelAllowlist, + SLACK_CHANNEL as channel, +} from "./shared.js"; + +function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig { + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { enabled: true }, + }); +} async function resolveSlackAllowFromEntries(params: { token?: string; @@ -89,45 +117,211 @@ async function promptSlackAllowFrom(params: { }); } -export const slackSetupWizard: ChannelSetupWizard = createSlackSetupWizardBase({ - promptAllowFrom: promptSlackAllowFrom, - resolveAllowFromEntries: async ({ credentialValues, entries }) => - await resolveSlackAllowFromEntries({ - token: credentialValues.botToken, - entries, - }), - resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { - let keys = entries; - const accountWithTokens = resolveSlackAccount({ +const slackDmPolicy: ChannelSetupDmPolicy = { + label: "Slack", + channel, + policyKey: "channels.slack.dmPolicy", + allowFromKey: "channels.slack.allowFrom", + getCurrent: (cfg) => + cfg.channels?.slack?.dmPolicy ?? cfg.channels?.slack?.dm?.policy ?? "pairing", + setPolicy: (cfg, policy) => + setLegacyChannelDmPolicyWithAllowFrom({ cfg, - accountId, - }); - const activeBotToken = accountWithTokens.botToken || credentialValues.botToken || ""; - if (activeBotToken && entries.length > 0) { - try { - const resolved = await resolveSlackChannelAllowlist({ - token: activeBotToken, - entries, - }); - const resolvedKeys = resolved - .filter((entry) => entry.resolved && entry.id) - .map((entry) => entry.id as string); - const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); - keys = [...resolvedKeys, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - await noteChannelLookupSummary({ - prompter, - label: "Slack channels", - resolvedSections: [{ title: "Resolved", values: resolvedKeys }], - unresolved, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: "Slack channels", - error, - }); - } - } - return keys; + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptSlackAllowFrom, +}; + +export const slackSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs tokens", + configuredHint: "configured", + unconfiguredHint: "needs tokens", + configuredScore: 2, + unconfiguredScore: 1, + resolveConfigured: ({ cfg }) => + listSlackAccountIds(cfg).some((accountId) => { + const account = inspectSlackAccount({ cfg, accountId }); + return account.configured; + }), }, -}); + introNote: { + title: "Slack socket mode tokens", + lines: buildSlackSetupLines(), + shouldShow: ({ cfg, accountId }) => + !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), + }, + envShortcut: { + prompt: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", + preferredEnvVar: "SLACK_BOT_TOKEN", + isAvailable: ({ cfg, accountId }) => + accountId === DEFAULT_ACCOUNT_ID && + Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && + Boolean(process.env.SLACK_APP_TOKEN?.trim()) && + !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), + apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), + }, + credentials: [ + { + inputKey: "botToken", + providerHint: "slack-bot", + credentialLabel: "Slack bot token", + preferredEnvVar: "SLACK_BOT_TOKEN", + envPrompt: "SLACK_BOT_TOKEN detected. Use env var?", + keepPrompt: "Slack bot token already configured. Keep it?", + inputPrompt: "Enter Slack bot token (xoxb-...)", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + return { + accountConfigured: + Boolean(resolved.botToken) || hasConfiguredSecretInput(resolved.config.botToken), + hasConfiguredValue: hasConfiguredSecretInput(resolved.config.botToken), + resolvedValue: resolved.botToken?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_BOT_TOKEN?.trim() : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), + applySet: ({ cfg, accountId, value }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { + enabled: true, + botToken: value, + }, + }), + }, + { + inputKey: "appToken", + providerHint: "slack-app", + credentialLabel: "Slack app token", + preferredEnvVar: "SLACK_APP_TOKEN", + envPrompt: "SLACK_APP_TOKEN detected. Use env var?", + keepPrompt: "Slack app token already configured. Keep it?", + inputPrompt: "Enter Slack app token (xapp-...)", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + return { + accountConfigured: + Boolean(resolved.appToken) || hasConfiguredSecretInput(resolved.config.appToken), + hasConfiguredValue: hasConfiguredSecretInput(resolved.config.appToken), + resolvedValue: resolved.appToken?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_APP_TOKEN?.trim() : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), + applySet: ({ cfg, accountId, value }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { + enabled: true, + appToken: value, + }, + }), + }, + ], + dmPolicy: slackDmPolicy, + allowFrom: { + helpTitle: "Slack allowlist", + helpLines: [ + "Allowlist Slack DMs by username (we resolve to user ids).", + "Examples:", + "- U12345678", + "- @alice", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/slack", "slack")}`, + ], + credentialInputKey: "botToken", + message: "Slack allowFrom (usernames or ids)", + placeholder: "@alice, U12345678", + invalidWithoutCredentialNote: "Slack token missing; use user ids (or mention form) only.", + parseId: (value) => + parseMentionOrPrefixedId({ + value, + mentionPattern: /^<@([A-Z0-9]+)>$/i, + prefixPattern: /^(slack:|user:)/i, + idPattern: /^[A-Z][A-Z0-9]+$/i, + normalizeId: (id) => id.toUpperCase(), + }), + resolveEntries: async ({ credentialValues, entries }) => + await resolveSlackAllowFromEntries({ + token: credentialValues.botToken, + entries, + }), + apply: ({ cfg, accountId, allowFrom }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + groupAccess: { + label: "Slack channels", + placeholder: "#general, #private, C123", + currentPolicy: ({ cfg, accountId }) => + resolveSlackAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }) => + Object.entries(resolveSlackAccount({ cfg, accountId }).config.channels ?? {}) + .filter(([, value]) => value?.allow !== false && value?.enabled !== false) + .map(([key]) => key), + updatePrompt: ({ cfg, accountId }) => + Boolean(resolveSlackAccount({ cfg, accountId }).config.channels), + setPolicy: ({ cfg, accountId, policy }) => + setAccountGroupPolicyForChannel({ + cfg, + channel, + accountId, + groupPolicy: policy, + }), + resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + let keys = entries; + const accountWithTokens = resolveSlackAccount({ + cfg, + accountId, + }); + const activeBotToken = accountWithTokens.botToken || credentialValues.botToken || ""; + if (activeBotToken && entries.length > 0) { + try { + const resolved = await resolveSlackChannelAllowlist({ + token: activeBotToken, + entries, + }); + const resolvedKeys = resolved + .filter((entry) => entry.resolved && entry.id) + .map((entry) => entry.id as string); + const unresolved = resolved + .filter((entry) => !entry.resolved) + .map((entry) => entry.input); + keys = [...resolvedKeys, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + await noteChannelLookupSummary({ + prompter, + label: "Slack channels", + resolvedSections: [{ title: "Resolved", values: resolvedKeys }], + unresolved, + }); + } catch (error) { + await noteChannelLookupFailure({ + prompter, + label: "Slack channels", + error, + }); + } + } + return keys; + }, + applyAllowlist: ({ cfg, accountId, resolved }) => + setSlackChannelAllowlist(cfg, accountId, resolved as string[]), + }, + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), +}; diff --git a/extensions/slack/src/stream-mode.ts b/extensions/slack/src/stream-mode.ts index 819eb4fa722..f341c0a5304 100644 --- a/extensions/slack/src/stream-mode.ts +++ b/extensions/slack/src/stream-mode.ts @@ -4,7 +4,7 @@ import { resolveSlackStreamingMode, type SlackLegacyDraftStreamMode, type StreamingMode, -} from "../../../src/config/discord-preview-streaming.js"; +} from "openclaw/plugin-sdk/config-runtime"; export type SlackStreamMode = SlackLegacyDraftStreamMode; export type SlackStreamingMode = StreamingMode; diff --git a/extensions/slack/src/streaming.ts b/extensions/slack/src/streaming.ts index b6269412c9d..1685e378f61 100644 --- a/extensions/slack/src/streaming.ts +++ b/extensions/slack/src/streaming.ts @@ -13,7 +13,7 @@ import type { WebClient } from "@slack/web-api"; import type { ChatStreamer } from "@slack/web-api/dist/chat-stream.js"; -import { logVerbose } from "../../../src/globals.js"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; // --------------------------------------------------------------------------- // Types diff --git a/extensions/slack/src/targets.ts b/extensions/slack/src/targets.ts index 5d80650daff..43162a447d5 100644 --- a/extensions/slack/src/targets.ts +++ b/extensions/slack/src/targets.ts @@ -6,7 +6,7 @@ import { type MessagingTarget, type MessagingTargetKind, type MessagingTargetParseOptions, -} from "../../../src/channels/targets.js"; +} from "openclaw/plugin-sdk/channel-runtime"; export type SlackTargetKind = MessagingTargetKind; diff --git a/extensions/slack/src/threading-tool-context.ts b/extensions/slack/src/threading-tool-context.ts index 206ce98b42f..30451be5b6b 100644 --- a/extensions/slack/src/threading-tool-context.ts +++ b/extensions/slack/src/threading-tool-context.ts @@ -1,8 +1,8 @@ import type { ChannelThreadingContext, ChannelThreadingToolContext, -} from "../../../src/channels/plugins/types.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js"; export function buildSlackThreadingToolContext(params: { diff --git a/extensions/slack/src/threading.ts b/extensions/slack/src/threading.ts index ccef2e5e081..d072ab796c0 100644 --- a/extensions/slack/src/threading.ts +++ b/extensions/slack/src/threading.ts @@ -1,4 +1,4 @@ -import type { ReplyToMode } from "../../../src/config/types.js"; +import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; import type { SlackAppMentionEvent, SlackMessageEvent } from "./types.js"; export type SlackThreadContext = { diff --git a/extensions/slack/src/token.ts b/extensions/slack/src/token.ts index cebda65e335..36f31c89383 100644 --- a/extensions/slack/src/token.ts +++ b/extensions/slack/src/token.ts @@ -1,4 +1,4 @@ -import { normalizeResolvedSecretInputString } from "../../../src/config/types.secrets.js"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; export function normalizeSlackToken(raw?: unknown): string | undefined { return normalizeResolvedSecretInputString({ diff --git a/extensions/synthetic/index.ts b/extensions/synthetic/index.ts index ed029dc7cce..19e7424bfb7 100644 --- a/extensions/synthetic/index.ts +++ b/extensions/synthetic/index.ts @@ -1,5 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applySyntheticConfig, SYNTHETIC_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildSyntheticProvider } from "./provider-catalog.js"; diff --git a/extensions/synthetic/onboard.ts b/extensions/synthetic/onboard.ts index 34199d4db2b..d11f2cb0e9b 100644 --- a/extensions/synthetic/onboard.ts +++ b/extensions/synthetic/onboard.ts @@ -3,12 +3,12 @@ import { SYNTHETIC_BASE_URL, SYNTHETIC_DEFAULT_MODEL_REF, SYNTHETIC_MODEL_CATALOG, -} from "../../src/agents/synthetic-models.js"; +} from "openclaw/plugin-sdk/provider-models"; import { applyAgentDefaultModelPrimary, applyProviderConfigWithModelCatalog, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; export { SYNTHETIC_DEFAULT_MODEL_REF }; diff --git a/extensions/synthetic/provider-catalog.ts b/extensions/synthetic/provider-catalog.ts index 181affdde2b..e46b08682c2 100644 --- a/extensions/synthetic/provider-catalog.ts +++ b/extensions/synthetic/provider-catalog.ts @@ -1,9 +1,9 @@ import { buildSyntheticModelDefinition, + type ModelProviderConfig, SYNTHETIC_BASE_URL, SYNTHETIC_MODEL_CATALOG, -} from "../../src/agents/synthetic-models.js"; -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +} from "openclaw/plugin-sdk/provider-models"; export function buildSyntheticProvider(): ModelProviderConfig { return { diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index 8f698262e3e..fb9e7bdb39d 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -1,6 +1,6 @@ +import { resolveActiveTalkProviderConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/talk-voice"; -import { resolveActiveTalkProviderConfig } from "../../src/config/talk.js"; -import type { SpeechVoiceOption } from "../../src/tts/provider-types.js"; function mask(s: string, keep: number = 6): string { const trimmed = s.trim(); diff --git a/extensions/telegram/src/account-inspect.ts b/extensions/telegram/src/account-inspect.ts index 1e428c237fa..6295a231451 100644 --- a/extensions/telegram/src/account-inspect.ts +++ b/extensions/telegram/src/account-inspect.ts @@ -1,14 +1,14 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-resolution"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { coerceSecretRef, hasConfiguredSecretInput, normalizeSecretInputString, -} from "../../../src/config/types.secrets.js"; -import { tryReadSecretFileSync } from "../../../src/infra/secret-file.js"; -import { resolveAccountWithDefaultFallback } from "../../../src/plugin-sdk-internal/accounts.js"; -import type { TelegramAccountConfig } from "../../../src/plugin-sdk-internal/telegram.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { resolveDefaultSecretProviderAlias } from "../../../src/secrets/ref-contract.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import type { TelegramAccountConfig } from "openclaw/plugin-sdk/telegram"; import { mergeTelegramAccountConfig, resolveDefaultTelegramAccountId, diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts index 6d2255e00a1..2e0c053d0d4 100644 --- a/extensions/telegram/src/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -1,27 +1,22 @@ import util from "node:util"; -import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { isTruthyEnvValue } from "../../../src/infra/env.js"; -import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; import { + createAccountActionGate, + DEFAULT_ACCOUNT_ID, listConfiguredAccountIds as listConfiguredAccountIdsFromSection, + normalizeAccountId, + normalizeOptionalAccountId, + resolveAccountEntry, resolveAccountWithDefaultFallback, -} from "../../../src/plugin-sdk-internal/accounts.js"; -import type { - TelegramAccountConfig, - TelegramActionConfig, -} from "../../../src/plugin-sdk-internal/telegram.js"; -import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/account-resolution"; +import { isTruthyEnvValue } from "openclaw/plugin-sdk/infra-runtime"; import { listBoundAccountIds, resolveDefaultAgentBoundAccountId, -} from "../../../src/routing/bindings.js"; -import { formatSetExplicitDefaultInstruction } from "../../../src/routing/default-account-warnings.js"; -import { - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - normalizeOptionalAccountId, -} from "../../../src/routing/session-key.js"; +} from "openclaw/plugin-sdk/routing"; +import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import type { TelegramAccountConfig, TelegramActionConfig } from "openclaw/plugin-sdk/telegram"; import { resolveTelegramToken } from "./token.js"; let log: ReturnType | null = null; diff --git a/extensions/telegram/src/api-logging.ts b/extensions/telegram/src/api-logging.ts index 6af9d7ae5a3..2abc74f0894 100644 --- a/extensions/telegram/src/api-logging.ts +++ b/extensions/telegram/src/api-logging.ts @@ -1,7 +1,7 @@ -import { danger } from "../../../src/globals.js"; -import { formatErrorMessage } from "../../../src/infra/errors.js"; -import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; +import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; export type TelegramApiLogger = (message: string) => void; diff --git a/extensions/telegram/src/approval-buttons.ts b/extensions/telegram/src/approval-buttons.ts index a996ed3adf3..8ce836c754b 100644 --- a/extensions/telegram/src/approval-buttons.ts +++ b/extensions/telegram/src/approval-buttons.ts @@ -1,4 +1,4 @@ -import type { ExecApprovalReplyDecision } from "../../../src/infra/exec-approval-reply.js"; +import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/infra-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; const MAX_CALLBACK_DATA_BYTES = 64; diff --git a/extensions/telegram/src/audit-membership-runtime.ts b/extensions/telegram/src/audit-membership-runtime.ts index 694ad338c5b..930d768778e 100644 --- a/extensions/telegram/src/audit-membership-runtime.ts +++ b/extensions/telegram/src/audit-membership-runtime.ts @@ -1,5 +1,5 @@ -import { isRecord } from "../../../src/utils.js"; -import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js"; +import { isRecord } from "openclaw/plugin-sdk/text-runtime"; +import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime"; import type { AuditTelegramGroupMembershipParams, TelegramGroupMembershipAudit, diff --git a/extensions/telegram/src/audit.ts b/extensions/telegram/src/audit.ts index 507f161edca..f7fb0969090 100644 --- a/extensions/telegram/src/audit.ts +++ b/extensions/telegram/src/audit.ts @@ -1,5 +1,5 @@ -import type { TelegramGroupConfig } from "../../../src/config/types.js"; -import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js"; +import type { TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-runtime"; export type TelegramGroupMembershipAuditEntry = { chatId: string; diff --git a/extensions/telegram/src/bot-access.ts b/extensions/telegram/src/bot-access.ts index bee8392e686..c89a8fe6f48 100644 --- a/extensions/telegram/src/bot-access.ts +++ b/extensions/telegram/src/bot-access.ts @@ -2,9 +2,9 @@ import { firstDefined, isSenderIdAllowed, mergeDmAllowFromSources, -} from "../../../src/channels/allow-from.js"; -import type { AllowlistMatch } from "../../../src/channels/allowlist-match.js"; -import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { AllowlistMatch } from "openclaw/plugin-sdk/channel-runtime"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; export type NormalizedAllowFrom = { entries: string[]; diff --git a/extensions/telegram/src/bot-handlers.ts b/extensions/telegram/src/bot-handlers.ts index 88e61e1c567..18db7c3405f 100644 --- a/extensions/telegram/src/bot-handlers.ts +++ b/extensions/telegram/src/bot-handlers.ts @@ -1,47 +1,47 @@ import type { Message, ReactionTypeEmoji } from "@grammyjs/types"; -import { resolveAgentDir, resolveDefaultAgentId } from "../../../src/agents/agent-scope.js"; -import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js"; -import { - createInboundDebouncer, - resolveInboundDebounceMs, -} from "../../../src/auto-reply/inbound-debounce.js"; -import { buildCommandsPaginationKeyboard } from "../../../src/auto-reply/reply/commands-info.js"; -import { - buildModelsProviderData, - formatModelsAvailableHeader, -} from "../../../src/auto-reply/reply/commands-models.js"; -import { resolveStoredModelOverride } from "../../../src/auto-reply/reply/model-selection.js"; -import { listSkillCommandsForAgents } from "../../../src/auto-reply/skill-commands.js"; -import { buildCommandsMessagePaginated } from "../../../src/auto-reply/status.js"; -import { shouldDebounceTextInbound } from "../../../src/channels/inbound-debounce-policy.js"; -import { resolveChannelConfigWrites } from "../../../src/channels/plugins/config-writes.js"; -import { loadConfig } from "../../../src/config/config.js"; -import { writeConfigFile } from "../../../src/config/io.js"; +import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; +import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveSessionStoreEntry, resolveStorePath, updateSessionStore, -} from "../../../src/config/sessions.js"; -import type { DmPolicy } from "../../../src/config/types.base.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../../../src/config/types.js"; -import { danger, logVerbose, warn } from "../../../src/globals.js"; -import { enqueueSystemEvent } from "../../../src/infra/system-events.js"; -import { MediaFetchError } from "../../../src/media/fetch.js"; -import { readChannelAllowFromStore } from "../../../src/pairing/pairing-store.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/config-runtime"; +import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; import { buildPluginBindingResolvedText, parsePluginBindingApprovalCustomId, resolvePluginConversationBindingApproval, -} from "../../../src/plugins/conversation-binding.js"; -import { dispatchPluginInteractiveHandler } from "../../../src/plugins/interactive.js"; -import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; -import { resolveThreadSessionKeys } from "../../../src/routing/session-key.js"; -import { applyModelOverrideToSessionEntry } from "../../../src/sessions/model-overrides.js"; +} from "openclaw/plugin-sdk/conversation-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime"; +import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime"; +import { + createInboundDebouncer, + resolveInboundDebounceMs, +} from "openclaw/plugin-sdk/reply-runtime"; +import { buildCommandsPaginationKeyboard } from "openclaw/plugin-sdk/reply-runtime"; +import { + buildModelsProviderData, + formatModelsAvailableHeader, +} from "openclaw/plugin-sdk/reply-runtime"; +import { resolveStoredModelOverride } from "openclaw/plugin-sdk/reply-runtime"; +import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/reply-runtime"; +import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { isSenderAllowed, diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts index 8290b02169d..5b4dc2f9cae 100644 --- a/extensions/telegram/src/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -2,29 +2,26 @@ import { findModelInCatalog, loadModelCatalog, modelSupportsVision, -} from "../../../src/agents/model-catalog.js"; -import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js"; -import { hasControlCommand } from "../../../src/auto-reply/command-detection.js"; -import { - recordPendingHistoryEntryIfEnabled, - type HistoryEntry, -} from "../../../src/auto-reply/reply/history.js"; -import { - buildMentionRegexes, - matchesMentionWithExplicit, -} from "../../../src/auto-reply/reply/mentions.js"; -import type { MsgContext } from "../../../src/auto-reply/templating.js"; -import { resolveControlCommandGate } from "../../../src/channels/command-gating.js"; -import { formatLocationText, type NormalizedLocation } from "../../../src/channels/location.js"; -import { logInboundDrop } from "../../../src/channels/logging.js"; -import { resolveMentionGatingWithBypass } from "../../../src/channels/mention-gating.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; +} from "openclaw/plugin-sdk/agent-runtime"; +import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime"; +import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime"; +import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../../../src/config/types.js"; -import { logVerbose } from "../../../src/globals.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; +import { + recordPendingHistoryEntryIfEnabled, + type HistoryEntry, +} from "openclaw/plugin-sdk/reply-runtime"; +import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime"; +import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { NormalizedAllowFrom } from "./bot-access.js"; import { isSenderAllowed } from "./bot-access.js"; import type { @@ -182,8 +179,7 @@ export async function resolveTelegramInboundBody(params: { if (needsPreflightTranscription) { try { - const { transcribeFirstAudio } = - await import("../../../src/media-understanding/audio-preflight.js"); + const { transcribeFirstAudio } = await import("openclaw/plugin-sdk/media-runtime"); const tempCtx: MsgContext = { MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, MediaTypes: diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 1a2f54cf22f..47bcda8592f 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -1,26 +1,26 @@ -import { normalizeCommandBody } from "../../../src/auto-reply/commands-registry.js"; -import { - formatInboundEnvelope, - resolveEnvelopeFormatOptions, -} from "../../../src/auto-reply/envelope.js"; -import { - buildPendingHistoryContextFromMap, - type HistoryEntry, -} from "../../../src/auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js"; -import { toLocationContext } from "../../../src/channels/location.js"; -import { recordInboundSession } from "../../../src/channels/session.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../../src/config/sessions.js"; +import { toLocationContext } from "openclaw/plugin-sdk/channel-runtime"; +import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../../../src/config/types.js"; -import { logVerbose, shouldLogVerbose } from "../../../src/globals.js"; -import type { ResolvedAgentRoute } from "../../../src/routing/resolve-route.js"; -import { resolveInboundLastRouteSessionKey } from "../../../src/routing/resolve-route.js"; -import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../src/security/dm-policy-shared.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { normalizeCommandBody } from "openclaw/plugin-sdk/reply-runtime"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "openclaw/plugin-sdk/reply-runtime"; +import { + buildPendingHistoryContextFromMap, + type HistoryEntry, +} from "openclaw/plugin-sdk/reply-runtime"; +import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; +import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; +import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; +import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; import { normalizeAllowFrom } from "./bot-access.js"; import type { TelegramMediaRef, @@ -63,7 +63,7 @@ export async function buildTelegramInboundContextPayload(params: { stickerCacheHit: boolean; effectiveWasMentioned: boolean; commandAuthorized: boolean; - locationData?: import("../../../src/channels/location.js").NormalizedLocation; + locationData?: import("openclaw/plugin-sdk/channel-runtime").NormalizedLocation; options?: TelegramMessageContextOptions; dmAllowFrom?: Array; }): Promise<{ diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 03bcd429018..d77fd52f2fc 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -1,17 +1,17 @@ -import { ensureConfiguredAcpRouteReady } from "../../../src/acp/persistent-bindings.route.js"; -import { resolveAckReaction } from "../../../src/agents/identity.js"; -import { shouldAckReaction as shouldAckReactionGate } from "../../../src/channels/ack-reactions.js"; -import { logInboundDrop } from "../../../src/channels/logging.js"; +import { resolveAckReaction } from "openclaw/plugin-sdk/agent-runtime"; +import { shouldAckReaction as shouldAckReactionGate } from "openclaw/plugin-sdk/channel-runtime"; +import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime"; import { createStatusReactionController, type StatusReactionController, -} from "../../../src/channels/status-reactions.js"; -import { loadConfig } from "../../../src/config/config.js"; -import type { TelegramDirectConfig, TelegramGroupConfig } from "../../../src/config/types.js"; -import { logVerbose } from "../../../src/globals.js"; -import { recordChannelActivity } from "../../../src/infra/channel-activity.js"; -import { buildAgentSessionKey, deriveLastRoutePolicy } from "../../../src/routing/resolve-route.js"; -import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../../../src/routing/session-key.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { TelegramDirectConfig, TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime"; +import { ensureConfiguredAcpRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; +import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; +import { buildAgentSessionKey, deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; +import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from "./bot-access.js"; import { resolveTelegramInboundBody } from "./bot-message-context.body.js"; diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts index 2853c1a8e34..ca0fbbf3376 100644 --- a/extensions/telegram/src/bot-message-context.types.ts +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -1,12 +1,12 @@ import type { Bot } from "grammy"; -import type { HistoryEntry } from "../../../src/auto-reply/reply/history.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { DmPolicy, TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../../../src/config/types.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; export type TelegramMediaRef = { diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 9b603393450..a8a4c376b0b 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -1,33 +1,33 @@ import type { Bot } from "grammy"; -import { resolveAgentDir } from "../../../src/agents/agent-scope.js"; +import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; import { findModelInCatalog, loadModelCatalog, modelSupportsVision, -} from "../../../src/agents/model-catalog.js"; -import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js"; -import { resolveChunkMode } from "../../../src/auto-reply/chunk.js"; -import { clearHistoryEntriesIfEnabled } from "../../../src/auto-reply/reply/history.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../../../src/auto-reply/reply/provider-dispatcher.js"; -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; -import { removeAckReactionAfterReply } from "../../../src/channels/ack-reactions.js"; -import { logAckFailure, logTypingFailure } from "../../../src/channels/logging.js"; -import { createReplyPrefixOptions } from "../../../src/channels/reply-prefix.js"; -import { createTypingCallbacks } from "../../../src/channels/typing.js"; -import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +} from "openclaw/plugin-sdk/agent-runtime"; +import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; +import { removeAckReactionAfterReply } from "openclaw/plugin-sdk/channel-runtime"; +import { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime"; +import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; +import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveSessionStoreEntry, resolveStorePath, -} from "../../../src/config/sessions.js"; +} from "openclaw/plugin-sdk/config-runtime"; import type { OpenClawConfig, ReplyToMode, TelegramAccountConfig, -} from "../../../src/config/types.js"; -import { danger, logVerbose } from "../../../src/globals.js"; -import { getAgentScopedMediaLocalRoots } from "../../../src/media/local-roots.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; +import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { TelegramMessageContext } from "./bot-message-context.js"; import type { TelegramBotOptions } from "./bot.js"; import { deliverReplies } from "./bot/delivery.js"; diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts index 0a5d44c65db..cb625c7b965 100644 --- a/extensions/telegram/src/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -1,7 +1,7 @@ -import type { ReplyToMode } from "../../../src/config/config.js"; -import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js"; -import { danger } from "../../../src/globals.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; +import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; +import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { buildTelegramMessageContext, type BuildTelegramMessageContextParams, diff --git a/extensions/telegram/src/bot-native-command-menu.ts b/extensions/telegram/src/bot-native-command-menu.ts index 73fa2d2345a..091a6e52c1b 100644 --- a/extensions/telegram/src/bot-native-command-menu.ts +++ b/extensions/telegram/src/bot-native-command-menu.ts @@ -3,13 +3,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { Bot } from "grammy"; -import { resolveStateDir } from "../../../src/config/paths.js"; import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN, -} from "../../../src/config/telegram-custom-commands.js"; -import { logVerbose } from "../../../src/globals.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; import { withTelegramApiErrorLogging } from "./api-logging.js"; export const TELEGRAM_MAX_COMMANDS = 100; diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index 33c3f04f904..a39bdd23da6 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -1,24 +1,24 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; +import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import type { MockFn } from "openclaw/plugin-sdk/test-utils"; import { vi } from "vitest"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js"; -import type { TelegramAccountConfig } from "../../../src/config/types.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; -import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; type RegisterTelegramNativeCommandsParams = Parameters[0]; type GetPluginCommandSpecsFn = - typeof import("../../../src/plugins/commands.js").getPluginCommandSpecs; -type MatchPluginCommandFn = typeof import("../../../src/plugins/commands.js").matchPluginCommand; + typeof import("openclaw/plugin-sdk/plugin-runtime").getPluginCommandSpecs; +type MatchPluginCommandFn = typeof import("openclaw/plugin-sdk/plugin-runtime").matchPluginCommand; type ExecutePluginCommandFn = - typeof import("../../../src/plugins/commands.js").executePluginCommand; + typeof import("openclaw/plugin-sdk/plugin-runtime").executePluginCommand; type DispatchReplyWithBufferedBlockDispatcherFn = - typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher; + typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher; type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< ReturnType >; type RecordInboundSessionMetaSafeFn = - typeof import("../../../src/channels/session-meta.js").recordInboundSessionMetaSafe; + typeof import("openclaw/plugin-sdk/channel-runtime").recordInboundSessionMetaSafe; type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; type NativeCommandHarness = { @@ -44,7 +44,7 @@ export const getPluginCommandSpecs = pluginCommandMocks.getPluginCommandSpecs; export const matchPluginCommand = pluginCommandMocks.matchPluginCommand; export const executePluginCommand = pluginCommandMocks.executePluginCommand; -vi.mock("../../../src/plugins/commands.js", () => ({ +vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({ getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, matchPluginCommand: pluginCommandMocks.matchPluginCommand, executePluginCommand: pluginCommandMocks.executePluginCommand, @@ -67,17 +67,17 @@ const replyPipelineMocks = vi.hoisted(() => { export const dispatchReplyWithBufferedBlockDispatcher = replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher; -vi.mock("../../../src/auto-reply/reply/inbound-context.js", () => ({ +vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ finalizeInboundContext: replyPipelineMocks.finalizeInboundContext, })); -vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ +vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ dispatchReplyWithBufferedBlockDispatcher: replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, })); -vi.mock("../../../src/channels/reply-prefix.js", () => ({ +vi.mock("openclaw/plugin-sdk/channel-runtime", () => ({ createReplyPrefixOptions: replyPipelineMocks.createReplyPrefixOptions, })); -vi.mock("../../../src/channels/session-meta.js", () => ({ +vi.mock("openclaw/plugin-sdk/channel-runtime", () => ({ recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe, })); @@ -86,7 +86,7 @@ const deliveryMocks = vi.hoisted(() => ({ })); export const deliverReplies = deliveryMocks.deliverReplies; vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies })); -vi.mock("../../../src/pairing/pairing-store.js", () => ({ +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ readChannelAllowFromStore: vi.fn(async () => []), })); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 64874d1f8eb..9cc757cec91 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -1,8 +1,33 @@ import type { Bot, Context } from "grammy"; -import { ensureConfiguredAcpRouteReady } from "../../../src/acp/persistent-bindings.route.js"; -import { resolveChunkMode } from "../../../src/auto-reply/chunk.js"; -import { resolveCommandAuthorization } from "../../../src/auto-reply/command-auth.js"; -import type { CommandArgs } from "../../../src/auto-reply/commands-registry.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/channel-runtime"; +import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; +import { recordInboundSessionMetaSafe } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { + normalizeTelegramCommandName, + resolveTelegramCustomCommands, + TELEGRAM_COMMAND_NAME_PATTERN, +} from "openclaw/plugin-sdk/config-runtime"; +import type { + ReplyToMode, + TelegramAccountConfig, + TelegramDirectConfig, + TelegramGroupConfig, + TelegramTopicConfig, +} from "openclaw/plugin-sdk/config-runtime"; +import { ensureConfiguredAcpRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; +import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { + executePluginCommand, + getPluginCommandSpecs, + matchPluginCommand, +} from "openclaw/plugin-sdk/plugin-runtime"; +import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveCommandAuthorization } from "openclaw/plugin-sdk/reply-runtime"; +import type { CommandArgs } from "openclaw/plugin-sdk/reply-runtime"; import { buildCommandTextFromArgs, findCommandByNativeName, @@ -10,40 +35,15 @@ import { listNativeCommandSpecsForConfig, parseCommandArgs, resolveCommandArgMenu, -} from "../../../src/auto-reply/commands-registry.js"; -import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../../../src/auto-reply/reply/provider-dispatcher.js"; -import { listSkillCommandsForAgents } from "../../../src/auto-reply/skill-commands.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js"; -import { resolveNativeCommandSessionTargets } from "../../../src/channels/native-command-session-targets.js"; -import { createReplyPrefixOptions } from "../../../src/channels/reply-prefix.js"; -import { recordInboundSessionMetaSafe } from "../../../src/channels/session-meta.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js"; -import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; -import { - normalizeTelegramCommandName, - resolveTelegramCustomCommands, - TELEGRAM_COMMAND_NAME_PATTERN, -} from "../../../src/config/telegram-custom-commands.js"; -import type { - ReplyToMode, - TelegramAccountConfig, - TelegramDirectConfig, - TelegramGroupConfig, - TelegramTopicConfig, -} from "../../../src/config/types.js"; -import { danger, logVerbose } from "../../../src/globals.js"; -import { getChildLogger } from "../../../src/logging.js"; -import { getAgentScopedMediaLocalRoots } from "../../../src/media/local-roots.js"; -import { - executePluginCommand, - getPluginCommandSpecs, - matchPluginCommand, -} from "../../../src/plugins/commands.js"; -import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; -import { resolveThreadSessionKeys } from "../../../src/routing/session-key.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; +} from "openclaw/plugin-sdk/reply-runtime"; +import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime"; +import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { isSenderAllowed, normalizeDmAllowFromWithStore } from "./bot-access.js"; import type { TelegramMediaRef } from "./bot-message-context.js"; diff --git a/extensions/telegram/src/bot-updates.ts b/extensions/telegram/src/bot-updates.ts index 3121f1a487e..4b08c747f8f 100644 --- a/extensions/telegram/src/bot-updates.ts +++ b/extensions/telegram/src/bot-updates.ts @@ -1,5 +1,5 @@ import type { Message } from "@grammyjs/types"; -import { createDedupeCache } from "../../../src/infra/dedupe.js"; +import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime"; import type { TelegramContext } from "./bot/types.js"; const MEDIA_GROUP_TIMEOUT_MS = 500; diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 2f151066910..69c0557ee3a 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -1,9 +1,9 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; +import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; +import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import type { MockFn } from "openclaw/plugin-sdk/test-utils"; import { beforeEach, vi } from "vitest"; -import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; -import type { MsgContext } from "../../../src/auto-reply/templating.js"; -import type { GetReplyOptions, ReplyPayload } from "../../../src/auto-reply/types.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; @@ -31,16 +31,16 @@ const { loadConfig } = vi.hoisted((): { loadConfig: AnyMock } => ({ export function getLoadConfigMock(): AnyMock { return loadConfig; } -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig, }; }); -vi.mock("../../../src/config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), @@ -68,7 +68,7 @@ export function getUpsertChannelPairingRequestMock(): AnyAsyncMock { return upsertChannelPairingRequest; } -vi.mock("../../../src/pairing/pairing-store.js", () => ({ +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ readChannelAllowFromStore, upsertChannelPairingRequest, })); @@ -78,7 +78,7 @@ const skillCommandsHoisted = vi.hoisted(() => ({ })); export const listSkillCommandsForAgents = skillCommandsHoisted.listSkillCommandsForAgents; -vi.mock("../../../src/auto-reply/skill-commands.js", () => ({ +vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ listSkillCommandsForAgents, })); @@ -87,7 +87,7 @@ const systemEventsHoisted = vi.hoisted(() => ({ })); export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy; -vi.mock("../../../src/infra/system-events.js", () => ({ +vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({ enqueueSystemEvent: enqueueSystemEventSpy, })); @@ -209,7 +209,7 @@ export const replySpy: MockFn< return undefined; }); -vi.mock("../../../src/auto-reply/reply.js", () => ({ +vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ getReplyFromConfig: replySpy, __replySpy: replySpy, })); diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts index a91362702dd..54ae862ce87 100644 --- a/extensions/telegram/src/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -1,5 +1,5 @@ +import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; import { beforeEach, vi, type Mock } from "vitest"; -import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; export const useSpy: Mock = vi.fn(); export const middlewareUseSpy: Mock = vi.fn(); @@ -92,8 +92,8 @@ vi.mock("undici", async (importOriginal) => { }; }); -vi.mock("../../../src/media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { + const actual = await importOriginal(); const mockModule = Object.create(null) as Record; Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); Object.defineProperty(mockModule, "saveMediaBuffer", { @@ -105,8 +105,8 @@ vi.mock("../../../src/media/store.js", async (importOriginal) => { return mockModule; }); -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => ({ @@ -115,15 +115,15 @@ vi.mock("../../../src/config/config.js", async (importOriginal) => { }; }); -vi.mock("../../../src/config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, updateLastRoute: vi.fn(async () => undefined), }; }); -vi.mock("../../../src/pairing/pairing-store.js", () => ({ +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ readChannelAllowFromStore: vi.fn(async () => [] as string[]), upsertChannelPairingRequest: vi.fn(async () => ({ code: "PAIRCODE", @@ -131,7 +131,7 @@ vi.mock("../../../src/pairing/pairing-store.js", () => ({ })), })); -vi.mock("../../../src/auto-reply/reply.js", () => { +vi.mock("openclaw/plugin-sdk/reply-runtime", () => { const replySpy = vi.fn(async (_ctx, opts) => { await opts?.onReplyStart?.(); return undefined; diff --git a/extensions/telegram/src/bot.media.test-utils.ts b/extensions/telegram/src/bot.media.test-utils.ts index fde76f34e23..3fee9271e3e 100644 --- a/extensions/telegram/src/bot.media.test-utils.ts +++ b/extensions/telegram/src/bot.media.test-utils.ts @@ -1,5 +1,5 @@ +import * as ssrf from "openclaw/plugin-sdk/infra-runtime"; import { afterEach, beforeAll, beforeEach, expect, vi, type Mock } from "vitest"; -import * as ssrf from "../../../src/infra/net/ssrf.js"; import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js"; type StickerSpy = Mock<(...args: unknown[]) => unknown>; @@ -103,7 +103,7 @@ afterEach(() => { beforeAll(async () => { ({ createTelegramBot: createTelegramBotRef } = await import("./bot.js")); - const replyModule = await import("../../../src/auto-reply/reply.js"); + const replyModule = await import("openclaw/plugin-sdk/reply-runtime"); replySpyRef = (replyModule as unknown as { __replySpy: ReturnType }).__replySpy; }, TELEGRAM_BOT_IMPORT_TIMEOUT_MS); diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts index a817e10cbac..6d1d7bc24b2 100644 --- a/extensions/telegram/src/bot.ts +++ b/extensions/telegram/src/bot.ts @@ -2,34 +2,31 @@ import { sequentialize } from "@grammyjs/runner"; import { apiThrottler } from "@grammyjs/transformer-throttler"; import type { ApiClientOptions } from "grammy"; import { Bot } from "grammy"; -import { resolveDefaultAgentId } from "../../../src/agents/agent-scope.js"; -import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; -import { - DEFAULT_GROUP_HISTORY_LIMIT, - type HistoryEntry, -} from "../../../src/auto-reply/reply/history.js"; +import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import { resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, resolveThreadBindingSpawnPolicy, -} from "../../../src/channels/thread-bindings-policy.js"; +} from "openclaw/plugin-sdk/channel-runtime"; import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, -} from "../../../src/config/commands.js"; -import type { OpenClawConfig, ReplyToMode } from "../../../src/config/config.js"; -import { loadConfig } from "../../../src/config/config.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, -} from "../../../src/config/group-policy.js"; -import { loadSessionStore, resolveStorePath } from "../../../src/config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose } from "../../../src/globals.js"; -import { formatUncaughtError } from "../../../src/infra/errors.js"; -import { getChildLogger } from "../../../src/logging.js"; -import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; -import { createNonExitingRuntime, type RuntimeEnv } from "../../../src/runtime.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { formatUncaughtError } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; +import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; +import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { resolveTelegramAccount } from "./accounts.js"; import { registerTelegramHandlers } from "./bot-handlers.js"; import { createTelegramMessageProcessor } from "./bot-message.js"; diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 2dfc1c8e956..d0a2d0fd610 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -1,25 +1,22 @@ import { type Bot, GrammyError, InputFile } from "grammy"; -import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../../src/auto-reply/chunk.js"; -import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; -import type { ReplyToMode } from "../../../../src/config/config.js"; -import type { MarkdownTableMode } from "../../../../src/config/types.base.js"; -import { danger, logVerbose } from "../../../../src/globals.js"; -import { fireAndForgetHook } from "../../../../src/hooks/fire-and-forget.js"; -import { - createInternalHookEvent, - triggerInternalHook, -} from "../../../../src/hooks/internal-hooks.js"; +import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; +import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { fireAndForgetHook } from "openclaw/plugin-sdk/hook-runtime"; +import { createInternalHookEvent, triggerInternalHook } from "openclaw/plugin-sdk/hook-runtime"; import { buildCanonicalSentMessageHookContext, toInternalMessageSentContext, toPluginMessageContext, toPluginMessageSentEvent, -} from "../../../../src/hooks/message-hook-mappers.js"; -import { formatErrorMessage } from "../../../../src/infra/errors.js"; -import { buildOutboundMediaLoadOptions } from "../../../../src/media/load-options.js"; -import { isGifMedia, kindFromMime } from "../../../../src/media/mime.js"; -import { getGlobalHookRunner } from "../../../../src/plugins/hook-runner-global.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; +} from "openclaw/plugin-sdk/hook-runtime"; +import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; +import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime"; +import { isGifMedia, kindFromMime } from "openclaw/plugin-sdk/media-runtime"; +import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; +import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { loadWebMedia } from "../../../whatsapp/src/media.js"; import type { TelegramInlineButtons } from "../button-types.js"; import { splitTelegramCaption } from "../caption.js"; diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index e42dd11aa1b..36b3bb50be9 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -1,9 +1,9 @@ import { GrammyError } from "grammy"; -import { logVerbose, warn } from "../../../../src/globals.js"; -import { formatErrorMessage } from "../../../../src/infra/errors.js"; -import { retryAsync } from "../../../../src/infra/retry.js"; -import { fetchRemoteMedia } from "../../../../src/media/fetch.js"; -import { saveMediaBuffer } from "../../../../src/media/store.js"; +import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; +import { retryAsync } from "openclaw/plugin-sdk/infra-runtime"; +import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; +import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; +import { logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; import { shouldRetryTelegramIpv4Fallback, type TelegramTransport } from "../fetch.js"; import { cacheSticker, getCachedSticker } from "../sticker-cache.js"; import { resolveTelegramMediaPlaceholder } from "./helpers.js"; diff --git a/extensions/telegram/src/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts index d8768899c28..9c0c6a77e10 100644 --- a/extensions/telegram/src/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -1,6 +1,6 @@ import { type Bot, GrammyError } from "grammy"; -import { formatErrorMessage } from "../../../../src/infra/errors.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { withTelegramApiErrorLogging } from "../api-logging.js"; import { markdownToTelegramHtml } from "../format.js"; import { buildInlineKeyboard } from "../send.js"; diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts index 3575da81efb..921cdf74e86 100644 --- a/extensions/telegram/src/bot/helpers.ts +++ b/extensions/telegram/src/bot/helpers.ts @@ -1,13 +1,13 @@ import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types"; -import { formatLocationText, type NormalizedLocation } from "../../../../src/channels/location.js"; -import { resolveTelegramPreviewStreamMode } from "../../../../src/config/discord-preview-streaming.js"; +import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveTelegramPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../../../../src/config/types.js"; -import { readChannelAllowFromStore } from "../../../../src/pairing/pairing-store.js"; -import { normalizeAccountId } from "../../../../src/routing/session-key.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js"; import type { TelegramStreamMode } from "./types.js"; diff --git a/extensions/telegram/src/bot/reply-threading.ts b/extensions/telegram/src/bot/reply-threading.ts index cdeeba7151b..11f4f099688 100644 --- a/extensions/telegram/src/bot/reply-threading.ts +++ b/extensions/telegram/src/bot/reply-threading.ts @@ -1,4 +1,4 @@ -import type { ReplyToMode } from "../../../../src/config/config.js"; +import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; export type DeliveryProgress = { hasReplied: boolean; diff --git a/extensions/telegram/src/button-types.ts b/extensions/telegram/src/button-types.ts index a6eae71995b..15c307ca8c0 100644 --- a/extensions/telegram/src/button-types.ts +++ b/extensions/telegram/src/button-types.ts @@ -1,9 +1,9 @@ -import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js"; +import { reduceInteractiveReply } from "openclaw/plugin-sdk/channel-runtime"; import { normalizeInteractiveReply, type InteractiveReply, type InteractiveReplyButton, -} from "../../../src/interactive/payload.js"; +} from "openclaw/plugin-sdk/channel-runtime"; export type TelegramButtonStyle = "danger" | "success" | "primary"; diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index c9ae46ca823..50c472ea600 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -3,20 +3,21 @@ import { readStringArrayParam, readStringOrNumberParam, readStringParam, -} from "../../../src/agents/tools/common.js"; -import { handleTelegramAction } from "../../../src/agents/tools/telegram-actions.js"; -import { resolveReactionMessageId } from "../../../src/channels/plugins/actions/reaction-message-id.js"; +} from "openclaw/plugin-sdk/agent-runtime"; +import { handleTelegramAction } from "openclaw/plugin-sdk/agent-runtime"; +import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; +import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime"; import { createUnionActionGate, listTokenSourcedAccounts, -} from "../../../src/channels/plugins/actions/shared.js"; +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageActionName, -} from "../../../src/channels/plugins/types.js"; -import type { TelegramActionConfig } from "../../../src/config/types.telegram.js"; -import { extractToolSend, readBooleanParam } from "../../../src/plugin-sdk-internal/telegram.js"; -import { resolveTelegramPollVisibility } from "../../../src/poll-params.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { TelegramActionConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveTelegramPollVisibility } from "openclaw/plugin-sdk/telegram"; +import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; import { createTelegramActionGate, listEnabledTelegramAccounts, diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index 0ed71ae568c..1da52dbe885 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -1,12 +1,69 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/telegram"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + TelegramConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/telegram"; import { type ResolvedTelegramAccount } from "./accounts.js"; +import { + findTelegramTokenOwnerAccountId, + formatDuplicateTelegramTokenReason, + telegramConfigAccessors, + telegramConfigBase, +} from "./plugin-shared.js"; import type { TelegramProbe } from "./probe.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; -import { createTelegramPluginBase } from "./shared.js"; -export const telegramSetupPlugin: ChannelPlugin = - createTelegramPluginBase({ - setupWizard: telegramSetupWizard, - setup: telegramSetupAdapter, - }); +export const telegramSetupPlugin: ChannelPlugin = { + id: "telegram", + meta: { + ...getChatChannelMeta("telegram"), + quickstartAllowFrom: true, + }, + setupWizard: telegramSetupWizard, + capabilities: { + chatTypes: ["direct", "group", "channel", "thread"], + reactions: true, + threads: true, + media: true, + polls: true, + nativeCommands: true, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.telegram"] }, + configSchema: buildChannelConfigSchema(TelegramConfigSchema), + config: { + ...telegramConfigBase, + isConfigured: (account, cfg) => { + if (!account.token?.trim()) { + return false; + } + return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + }, + unconfiguredReason: (account, cfg) => { + if (!account.token?.trim()) { + return "not configured"; + } + const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + if (!ownerAccountId) { + return "not configured"; + } + return formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }); + }, + describeAccount: (account, cfg) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: + Boolean(account.token?.trim()) && + !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), + tokenSource: account.tokenSource, + }), + ...telegramConfigAccessors, + }, + setup: telegramSetupAdapter, +}; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 797b60c85d8..e157ea34ba7 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,18 +1,25 @@ +import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - buildAccountScopedAllowlistConfigEditor, collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, createScopedDmSecurityResolver, -} from "openclaw/plugin-sdk/compat"; +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { type OutboundSendDeps, resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; import { buildAgentSessionKey, resolveThreadSessionKeys, type RoutePeer, } from "openclaw/plugin-sdk/core"; +import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; +import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra-runtime"; +import { parseTelegramTopicConversation } from "openclaw/plugin-sdk/telegram"; import { + buildChannelConfigSchema, buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, + getChatChannelMeta, listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, PAIRING_APPROVED_MESSAGE, @@ -20,23 +27,13 @@ import { resolveConfiguredFromCredentialStatuses, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, - type ChannelMessageActionAdapter, + TelegramConfigSchema, type ChannelPlugin, + type ChannelMessageActionAdapter, type OpenClawConfig, } from "openclaw/plugin-sdk/telegram"; -import { parseTelegramTopicConversation } from "../../../src/acp/conversation-id.js"; -import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js"; -import { buildExecApprovalPendingReplyPayload } from "../../../src/infra/exec-approval-reply.js"; -import { - type OutboundSendDeps, - resolveOutboundSendDep, -} from "../../../src/infra/outbound/send-deps.js"; -import { normalizeOutboundThreadId } from "../../../src/infra/outbound/thread-id.js"; -import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; -import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, - resolveDefaultTelegramAccountId, resolveTelegramAccount, type ResolvedTelegramAccount, } from "./accounts.js"; @@ -51,17 +48,17 @@ 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 { + findTelegramTokenOwnerAccountId, + formatDuplicateTelegramTokenReason, + telegramConfigAccessors, + telegramConfigBase, +} from "./plugin-shared.js"; import { probeTelegram, type TelegramProbe } from "./probe.js"; import { getTelegramRuntime } from "./runtime.js"; import { sendTypingTelegram } from "./send.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; -import { - createTelegramPluginBase, - findTelegramTokenOwnerAccountId, - formatDuplicateTelegramTokenReason, - telegramConfigAccessors, -} from "./shared.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; @@ -69,6 +66,8 @@ type TelegramSendFn = ReturnType< typeof getTelegramRuntime >["channel"]["telegram"]["sendMessageTelegram"]; +const meta = getChatChannelMeta("telegram"); + type TelegramSendOptions = NonNullable[2]>; function buildTelegramSendOptions(params: { @@ -186,6 +185,20 @@ function parseTelegramExplicitTarget(raw: string) { }; } +function normalizeOutboundThreadId(value?: string | number | null): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return undefined; + } + return String(Math.trunc(value)); + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + function buildTelegramBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; @@ -311,10 +324,12 @@ function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) { } export const telegramPlugin: ChannelPlugin = { - ...createTelegramPluginBase({ - setupWizard: telegramSetupWizard, - setup: telegramSetupAdapter, - }), + id: "telegram", + meta: { + ...meta, + quickstartAllowFrom: true, + }, + setupWizard: telegramSetupWizard, pairing: { idLabel: "telegramUserId", normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""), @@ -332,6 +347,49 @@ export const telegramPlugin: ChannelPlugin { + if (!account.token?.trim()) { + return false; + } + return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + }, + unconfiguredReason: (account, cfg) => { + if (!account.token?.trim()) { + return "not configured"; + } + const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + if (!ownerAccountId) { + return "not configured"; + } + return formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }); + }, + describeAccount: (account, cfg) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: + Boolean(account.token?.trim()) && + !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), + tokenSource: account.tokenSource, + }), + ...telegramConfigAccessors, + }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => @@ -487,6 +545,7 @@ export const telegramPlugin: ChannelPlugin listTelegramDirectoryGroupsFromConfig(params), }, actions: telegramMessageActions, + setup: telegramSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index f12c896d0ca..fc06221936f 100644 --- a/extensions/telegram/src/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -1,18 +1,18 @@ -import { resolveConfiguredAcpRoute } from "../../../src/acp/persistent-bindings.route.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { logVerbose } from "../../../src/globals.js"; -import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js"; -import { isPluginOwnedSessionBindingRecord } from "../../../src/plugins/conversation-binding.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveConfiguredAcpRoute } from "openclaw/plugin-sdk/conversation-runtime"; +import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime"; +import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; import { buildAgentSessionKey, deriveLastRoutePolicy, resolveAgentRoute, -} from "../../../src/routing/resolve-route.js"; +} from "openclaw/plugin-sdk/routing"; import { buildAgentMainSessionKey, resolveAgentIdFromSessionKey, sanitizeAgentId, -} from "../../../src/routing/session-key.js"; +} from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { buildTelegramGroupPeerId, buildTelegramParentPeer, diff --git a/extensions/telegram/src/dm-access.ts b/extensions/telegram/src/dm-access.ts index db8cc419c6a..5bcacf95567 100644 --- a/extensions/telegram/src/dm-access.ts +++ b/extensions/telegram/src/dm-access.ts @@ -1,9 +1,9 @@ import type { Message } from "@grammyjs/types"; import type { Bot } from "grammy"; -import type { DmPolicy } from "../../../src/config/types.js"; -import { logVerbose } from "../../../src/globals.js"; -import { issuePairingChallenge } from "../../../src/pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../../src/pairing/pairing-store.js"; +import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; +import { issuePairingChallenge } from "openclaw/plugin-sdk/conversation-runtime"; +import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { resolveSenderAllowMatch, type NormalizedAllowFrom } from "./bot-access.js"; diff --git a/extensions/telegram/src/draft-chunking.ts b/extensions/telegram/src/draft-chunking.ts index 76edc1b1811..42911f4fd0e 100644 --- a/extensions/telegram/src/draft-chunking.ts +++ b/extensions/telegram/src/draft-chunking.ts @@ -1,7 +1,7 @@ -import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.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 { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; const DEFAULT_TELEGRAM_DRAFT_STREAM_MIN = 200; diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index 5641b042d30..baebe687c50 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -1,6 +1,6 @@ import type { Bot } from "grammy"; -import { createFinalizableDraftLifecycle } from "../../../src/channels/draft-stream-controls.js"; -import { resolveGlobalSingleton } from "../../../src/shared/global-singleton.js"; +import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveGlobalSingleton } from "openclaw/plugin-sdk/text-runtime"; import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js"; import { isSafeToRetrySendError, isTelegramClientRejection } from "./network-errors.js"; diff --git a/extensions/telegram/src/exec-approvals-handler.ts b/extensions/telegram/src/exec-approvals-handler.ts index a9d32d0887d..97cc2228b98 100644 --- a/extensions/telegram/src/exec-approvals-handler.ts +++ b/extensions/telegram/src/exec-approvals-handler.ts @@ -1,21 +1,18 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { GatewayClient } from "../../../src/gateway/client.js"; -import { createOperatorApprovalsGatewayClient } from "../../../src/gateway/operator-approvals-client.js"; -import type { EventFrame } from "../../../src/gateway/protocol/index.js"; -import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime"; +import { createOperatorApprovalsGatewayClient } from "openclaw/plugin-sdk/gateway-runtime"; +import type { EventFrame } from "openclaw/plugin-sdk/gateway-runtime"; +import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; import { buildExecApprovalPendingReplyPayload, type ExecApprovalPendingReplyParams, -} from "../../../src/infra/exec-approval-reply.js"; -import { resolveExecApprovalSessionTarget } from "../../../src/infra/exec-approval-session-target.js"; -import type { - ExecApprovalRequest, - ExecApprovalResolved, -} from "../../../src/infra/exec-approvals.js"; -import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; -import { normalizeAccountId, parseAgentSessionKey } from "../../../src/routing/session-key.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; -import { compileSafeRegex, testRegexWithBoundedInput } from "../../../src/security/safe-regex.js"; +} from "openclaw/plugin-sdk/infra-runtime"; +import { resolveExecApprovalSessionTarget } from "openclaw/plugin-sdk/infra-runtime"; +import type { ExecApprovalRequest, ExecApprovalResolved } from "openclaw/plugin-sdk/infra-runtime"; +import { normalizeAccountId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { compileSafeRegex, testRegexWithBoundedInput } from "openclaw/plugin-sdk/security-runtime"; import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; import { getTelegramExecApprovalApprovers, diff --git a/extensions/telegram/src/exec-approvals.ts b/extensions/telegram/src/exec-approvals.ts index b1b0eed8d4f..10ae8dd35a0 100644 --- a/extensions/telegram/src/exec-approvals.ts +++ b/extensions/telegram/src/exec-approvals.ts @@ -1,7 +1,7 @@ -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { TelegramExecApprovalConfig } from "../../../src/config/types.telegram.js"; -import { getExecApprovalReplyMetadata } from "../../../src/infra/exec-approval-reply.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { TelegramExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/infra-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramTargetChatType } from "./targets.js"; diff --git a/extensions/telegram/src/fetch.ts b/extensions/telegram/src/fetch.ts index 4b234c8d107..962d0256af1 100644 --- a/extensions/telegram/src/fetch.ts +++ b/extensions/telegram/src/fetch.ts @@ -1,10 +1,10 @@ import * as dns from "node:dns"; +import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveFetch } from "openclaw/plugin-sdk/infra-runtime"; +import { hasEnvHttpProxyConfigured } from "openclaw/plugin-sdk/infra-runtime"; +import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici"; -import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js"; -import { resolveFetch } from "../../../src/infra/fetch.js"; -import { hasEnvHttpProxyConfigured } from "../../../src/infra/net/proxy-env.js"; -import type { PinnedDispatcherPolicy } from "../../../src/infra/net/ssrf.js"; -import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; import { resolveTelegramAutoSelectFamilyDecision, resolveTelegramDnsResultOrderDecision, diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index 0c1bec2a62a..a9a10965243 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -1,11 +1,11 @@ -import type { MarkdownTableMode } from "../../../src/config/types.base.js"; +import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan, type MarkdownIR, -} from "../../../src/markdown/ir.js"; -import { renderMarkdownWithMarkers } from "../../../src/markdown/render.js"; +} from "openclaw/plugin-sdk/text-runtime"; +import { renderMarkdownWithMarkers } from "openclaw/plugin-sdk/text-runtime"; export type TelegramFormattedChunk = { html: string; diff --git a/extensions/telegram/src/group-access.ts b/extensions/telegram/src/group-access.ts index e42646a7dcd..d4802a9f0cf 100644 --- a/extensions/telegram/src/group-access.ts +++ b/extensions/telegram/src/group-access.ts @@ -1,13 +1,13 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js"; -import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; +import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramAccountConfig, TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../../../src/config/types.js"; -import { evaluateMatchedGroupAccessForPolicy } from "../../../src/plugin-sdk-internal/telegram.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js"; import { firstDefined } from "./bot-access.js"; diff --git a/extensions/telegram/src/group-config-helpers.ts b/extensions/telegram/src/group-config-helpers.ts index 5a60d116dd3..8c0f4652282 100644 --- a/extensions/telegram/src/group-config-helpers.ts +++ b/extensions/telegram/src/group-config-helpers.ts @@ -2,7 +2,7 @@ import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, -} from "../../../src/config/types.js"; +} from "openclaw/plugin-sdk/config-runtime"; import { firstDefined } from "./bot-access.js"; export function resolveTelegramGroupPromptSettings(params: { diff --git a/extensions/telegram/src/group-migration.ts b/extensions/telegram/src/group-migration.ts index 0609fcf4b5a..95b4529e51f 100644 --- a/extensions/telegram/src/group-migration.ts +++ b/extensions/telegram/src/group-migration.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { TelegramGroupConfig } from "../../../src/config/types.telegram.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; type TelegramGroups = Record; diff --git a/extensions/telegram/src/inline-buttons.ts b/extensions/telegram/src/inline-buttons.ts index ead8068feba..5341f2d09f1 100644 --- a/extensions/telegram/src/inline-buttons.ts +++ b/extensions/telegram/src/inline-buttons.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { TelegramInlineButtonsScope } from "../../../src/config/types.telegram.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { TelegramInlineButtonsScope } from "openclaw/plugin-sdk/config-runtime"; import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; const DEFAULT_INLINE_BUTTONS_SCOPE: TelegramInlineButtonsScope = "allowlist"; diff --git a/extensions/telegram/src/lane-delivery-text-deliverer.ts b/extensions/telegram/src/lane-delivery-text-deliverer.ts index 08875329649..c99dc52661a 100644 --- a/extensions/telegram/src/lane-delivery-text-deliverer.ts +++ b/extensions/telegram/src/lane-delivery-text-deliverer.ts @@ -1,4 +1,4 @@ -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; import type { TelegramDraftStream } from "./draft-stream.js"; import { diff --git a/extensions/telegram/src/monitor.ts b/extensions/telegram/src/monitor.ts index 8620fb01c2b..11530ad66ef 100644 --- a/extensions/telegram/src/monitor.ts +++ b/extensions/telegram/src/monitor.ts @@ -1,11 +1,11 @@ import type { RunOptions } from "@grammyjs/runner"; -import { resolveAgentMaxConcurrent } from "../../../src/config/agent-limits.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { loadConfig } from "../../../src/config/config.js"; -import { waitForAbortSignal } from "../../../src/infra/abort-signal.js"; -import { formatErrorMessage } from "../../../src/infra/errors.js"; -import { registerUnhandledRejectionHandler } from "../../../src/infra/unhandled-rejections.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; +import { resolveAgentMaxConcurrent } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; +import { waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env"; +import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js"; diff --git a/extensions/telegram/src/network-config.ts b/extensions/telegram/src/network-config.ts index 81156ce67ac..a37a8656203 100644 --- a/extensions/telegram/src/network-config.ts +++ b/extensions/telegram/src/network-config.ts @@ -1,7 +1,7 @@ import process from "node:process"; -import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js"; -import { isTruthyEnvValue } from "../../../src/infra/env.js"; -import { isWSL2Sync } from "../../../src/infra/wsl.js"; +import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-runtime"; +import { isTruthyEnvValue } from "openclaw/plugin-sdk/infra-runtime"; +import { isWSL2Sync } from "openclaw/plugin-sdk/infra-runtime"; export const TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV = "OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY"; diff --git a/extensions/telegram/src/network-errors.ts b/extensions/telegram/src/network-errors.ts index 59753f9d8c1..1e7c8523767 100644 --- a/extensions/telegram/src/network-errors.ts +++ b/extensions/telegram/src/network-errors.ts @@ -3,7 +3,7 @@ import { extractErrorCode, formatErrorMessage, readErrorName, -} from "../../../src/infra/errors.js"; +} from "openclaw/plugin-sdk/infra-runtime"; const TELEGRAM_NETWORK_ORIGIN = Symbol("openclaw.telegram.network-origin"); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 25bd2329ed7..1b12c5203a1 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -1,14 +1,11 @@ -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; import { resolvePayloadMediaUrls, sendPayloadMediaSequence, -} from "../../../src/channels/plugins/outbound/direct-text-media.js"; -import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; -import { - resolveOutboundSendDep, - type OutboundSendDeps, -} from "../../../src/infra/outbound/send-deps.js"; -import { resolveInteractiveTextFallback } from "../../../src/interactive/payload.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveInteractiveTextFallback } from "openclaw/plugin-sdk/channel-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; import { markdownToTelegramHtmlChunks } from "./format.js"; diff --git a/extensions/telegram/src/plugin-shared.ts b/extensions/telegram/src/plugin-shared.ts index 4d33a6ed6f8..12562f0da61 100644 --- a/extensions/telegram/src/plugin-shared.ts +++ b/extensions/telegram/src/plugin-shared.ts @@ -1,12 +1,9 @@ +import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, - formatAllowFromLowercase, -} from "../../../src/plugin-sdk-internal/channel-config.js"; -import { - normalizeAccountId, - type OpenClawConfig, -} from "../../../src/plugin-sdk-internal/telegram.js"; +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { normalizeAccountId, type OpenClawConfig } from "openclaw/plugin-sdk/telegram"; import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, diff --git a/extensions/telegram/src/polling-session.ts b/extensions/telegram/src/polling-session.ts index 5506ce4e434..89342994387 100644 --- a/extensions/telegram/src/polling-session.ts +++ b/extensions/telegram/src/polling-session.ts @@ -1,7 +1,7 @@ import { type RunOptions, run } from "@grammyjs/runner"; -import { computeBackoff, sleepWithAbort } from "../../../src/infra/backoff.js"; -import { formatErrorMessage } from "../../../src/infra/errors.js"; -import { formatDurationPrecise } from "../../../src/infra/format-time/format-duration.ts"; +import { computeBackoff, sleepWithAbort } from "openclaw/plugin-sdk/infra-runtime"; +import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; +import { formatDurationPrecise } from "openclaw/plugin-sdk/infra-runtime"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { createTelegramBot } from "./bot.js"; import { isRecoverableTelegramNetworkError } from "./network-errors.js"; diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index cade90c5ad5..660b9c9fb62 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -1,6 +1,6 @@ -import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; -import type { TelegramNetworkConfig } from "../../../src/plugin-sdk-internal/telegram.js"; -import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime"; +import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/telegram"; +import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime"; import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; diff --git a/extensions/telegram/src/proxy.ts b/extensions/telegram/src/proxy.ts index d74710c9cbd..1a06877b90f 100644 --- a/extensions/telegram/src/proxy.ts +++ b/extensions/telegram/src/proxy.ts @@ -1 +1 @@ -export { getProxyUrlFromFetch, makeProxyFetch } from "../../../src/infra/net/proxy-fetch.js"; +export { getProxyUrlFromFetch, makeProxyFetch } from "openclaw/plugin-sdk/infra-runtime"; diff --git a/extensions/telegram/src/reaction-level.ts b/extensions/telegram/src/reaction-level.ts index 4597ce0602e..3f33277d19a 100644 --- a/extensions/telegram/src/reaction-level.ts +++ b/extensions/telegram/src/reaction-level.ts @@ -1,9 +1,9 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveReactionLevel, type ReactionLevel, type ResolvedReactionLevel as BaseResolvedReactionLevel, -} from "../../../src/utils/reaction-level.js"; +} from "openclaw/plugin-sdk/text-runtime"; import { resolveTelegramAccount } from "./accounts.js"; export type TelegramReactionLevel = ReactionLevel; diff --git a/extensions/telegram/src/reasoning-lane-coordinator.ts b/extensions/telegram/src/reasoning-lane-coordinator.ts index 4bc0da94dfe..a4e414a6727 100644 --- a/extensions/telegram/src/reasoning-lane-coordinator.ts +++ b/extensions/telegram/src/reasoning-lane-coordinator.ts @@ -1,7 +1,7 @@ -import { formatReasoningMessage } from "../../../src/agents/pi-embedded-utils.js"; -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; -import { findCodeRegions, isInsideCode } from "../../../src/shared/text/code-regions.js"; -import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js"; +import { formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { findCodeRegions, isInsideCode } from "openclaw/plugin-sdk/text-runtime"; +import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-runtime"; const REASONING_MESSAGE_PREFIX = "Reasoning:\n"; const REASONING_TAG_PREFIXES = [ diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts index 768c15e28f5..1cc0c75b5dc 100644 --- a/extensions/telegram/src/runtime.ts +++ b/extensions/telegram/src/runtime.ts @@ -1,7 +1,5 @@ -import { - createPluginRuntimeStore, - type PluginRuntime, -} from "../../../src/plugin-sdk-internal/core.js"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } = createPluginRuntimeStore("Telegram runtime not initialized"); diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index 604a7d27dd1..c12a571c642 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -1,5 +1,5 @@ +import type { MockFn } from "openclaw/plugin-sdk/test-utils"; import { beforeEach, vi } from "vitest"; -import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; const { botApi, botCtorSpy } = vi.hoisted(() => ({ botApi: { @@ -64,8 +64,8 @@ vi.mock("grammy", () => ({ InputFile: class {}, })); -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig, diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index b215be835e8..0682fda6786 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -5,20 +5,20 @@ import type { ReactionTypeEmoji, } from "@grammyjs/types"; import { type ApiClientOptions, Bot, HttpError, InputFile } from "grammy"; -import { loadConfig } from "../../../src/config/config.js"; -import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; -import { logVerbose } from "../../../src/globals.js"; -import { recordChannelActivity } from "../../../src/infra/channel-activity.js"; -import { isDiagnosticFlagEnabled } from "../../../src/infra/diagnostic-flags.js"; -import { formatErrorMessage, formatUncaughtError } from "../../../src/infra/errors.js"; -import { createTelegramRetryRunner } from "../../../src/infra/retry-policy.js"; -import type { RetryConfig } from "../../../src/infra/retry.js"; -import { redactSensitiveText } from "../../../src/logging/redact.js"; -import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; -import type { MediaKind } from "../../../src/media/constants.js"; -import { buildOutboundMediaLoadOptions } from "../../../src/media/load-options.js"; -import { isGifMedia, kindFromMime } from "../../../src/media/mime.js"; -import { normalizePollInput, type PollInput } from "../../../src/polls.js"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; +import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/infra-runtime"; +import { formatErrorMessage, formatUncaughtError } from "openclaw/plugin-sdk/infra-runtime"; +import { createTelegramRetryRunner } from "openclaw/plugin-sdk/infra-runtime"; +import type { RetryConfig } from "openclaw/plugin-sdk/infra-runtime"; +import type { MediaKind } from "openclaw/plugin-sdk/media-runtime"; +import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime"; +import { isGifMedia, kindFromMime } from "openclaw/plugin-sdk/media-runtime"; +import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/media-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { redactSensitiveText } from "openclaw/plugin-sdk/text-runtime"; import { loadWebMedia } from "../../whatsapp/src/media.js"; import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; diff --git a/extensions/telegram/src/sendchataction-401-backoff.ts b/extensions/telegram/src/sendchataction-401-backoff.ts index 72ac8690403..0c9865eb2b3 100644 --- a/extensions/telegram/src/sendchataction-401-backoff.ts +++ b/extensions/telegram/src/sendchataction-401-backoff.ts @@ -1,4 +1,8 @@ -import { computeBackoff, sleepWithAbort, type BackoffPolicy } from "../../../src/infra/backoff.js"; +import { + computeBackoff, + sleepWithAbort, + type BackoffPolicy, +} from "openclaw/plugin-sdk/infra-runtime"; export type TelegramSendChatActionLogger = (message: string) => void; diff --git a/extensions/telegram/src/sent-message-cache.ts b/extensions/telegram/src/sent-message-cache.ts index 49a6ab4c3d9..bb48bce3c0f 100644 --- a/extensions/telegram/src/sent-message-cache.ts +++ b/extensions/telegram/src/sent-message-cache.ts @@ -1,4 +1,4 @@ -import { resolveGlobalMap } from "../../../src/shared/global-singleton.js"; +import { resolveGlobalMap } from "openclaw/plugin-sdk/text-runtime"; /** * In-memory cache of sent message IDs per chat. diff --git a/extensions/telegram/src/sequential-key.ts b/extensions/telegram/src/sequential-key.ts index 334c18dc485..5309a88a32c 100644 --- a/extensions/telegram/src/sequential-key.ts +++ b/extensions/telegram/src/sequential-key.ts @@ -1,6 +1,6 @@ import { type Message, type UserFromGetMe } from "@grammyjs/types"; -import { isAbortRequestText } from "../../../src/auto-reply/reply/abort.js"; -import { isBtwRequestText } from "../../../src/auto-reply/reply/btw-command.js"; +import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime"; +import { isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime"; import { resolveTelegramForumThreadId } from "./bot/helpers.js"; export type TelegramSequentialKeyContext = { diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index 33ce824d17d..13fb01f3a51 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,27 +1,18 @@ -import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import { + applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, formatCliCommand, + formatDocsLink, + migrateBaseNameToDefaultAccount, + normalizeAccountId, patchChannelConfigForAccount, promptResolvedAllowFrom, - setSetupChannelEnabled, - setChannelDmPolicyWithAllowFrom, splitSetupEntries, type OpenClawConfig, type WizardPrompter, -} from "../../../src/plugin-sdk-internal/setup.js"; -import type { - ChannelSetupAdapter, - ChannelSetupDmPolicy, - ChannelSetupWizard, -} from "../../../src/plugin-sdk-internal/setup.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import { inspectTelegramAccount } from "./account-inspect.js"; -import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, -} from "./accounts.js"; +} from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; +import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; import { fetchTelegramChatId } from "./api-fetch.js"; const channel = "telegram" as const; @@ -118,93 +109,15 @@ export async function promptTelegramAllowFromForAccount(params: { }); } -type TelegramSetupWizardHandlers = { - inspectToken: (params: { cfg: OpenClawConfig; accountId: string }) => { - accountConfigured: boolean; - hasConfiguredValue: boolean; - resolvedValue?: string; - envValue?: string; - }; -}; - -export function createTelegramSetupWizardBase( - handlers: TelegramSetupWizardHandlers, -): ChannelSetupWizard { - const dmPolicy: ChannelSetupDmPolicy = { - label: "Telegram", - channel, - policyKey: "channels.telegram.dmPolicy", - allowFromKey: "channels.telegram.allowFrom", - getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptTelegramAllowFromForAccount, - }; - - return { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs token", - configuredHint: "recommended · configured", - unconfiguredHint: "recommended · newcomer-friendly", - configuredScore: 1, - unconfiguredScore: 10, - resolveConfigured: ({ cfg }) => - listTelegramAccountIds(cfg).some((accountId) => { - const account = inspectTelegramAccount({ cfg, accountId }); - return account.configured; - }), - }, - credentials: [ - { - inputKey: "token", - providerHint: channel, - credentialLabel: "Telegram bot token", - preferredEnvVar: "TELEGRAM_BOT_TOKEN", - helpTitle: "Telegram bot token", - helpLines: TELEGRAM_TOKEN_HELP_LINES, - envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", - keepPrompt: "Telegram token already configured. Keep it?", - inputPrompt: "Enter Telegram bot token", - allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => handlers.inspectToken({ cfg, accountId }), - }, - ], - allowFrom: { - helpTitle: "Telegram user id", - helpLines: TELEGRAM_USER_ID_HELP_LINES, - credentialInputKey: "token", - message: "Telegram allowFrom (numeric sender id; @username resolves to id)", - placeholder: "@username", - invalidWithoutCredentialNote: - "Telegram token missing; use numeric sender ids (usernames require a bot token).", - parseInputs: splitSetupEntries, - parseId: parseTelegramAllowFromId, - resolveEntries: async ({ credentialValues, entries }) => - resolveTelegramAllowFromEntries({ - credentialValue: credentialValues.token, - entries, - }), - apply: async ({ cfg, accountId, allowFrom }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), - }, - dmPolicy, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), - } satisfies ChannelSetupWizard; -} - -export const telegramSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ - channelKey: channel, +export const telegramSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "TELEGRAM_BOT_TOKEN can only be used for the default account."; @@ -214,12 +127,60 @@ export const telegramSetupAdapter: ChannelSetupAdapter = createPatchedAccountSet } return null; }, - buildPatch: (input) => - input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}, -}); + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + telegram: { + ...next.channels?.telegram, + enabled: true, + ...(input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + telegram: { + ...next.channels?.telegram, + enabled: true, + accounts: { + ...next.channels?.telegram?.accounts, + [accountId]: { + ...next.channels?.telegram?.accounts?.[accountId], + enabled: true, + ...(input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}), + }, + }, + }, + }, + }; + }, +}; diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index 4417fc1764a..934fa0688e9 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -1,30 +1,110 @@ import { DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, -} from "../../../src/plugin-sdk-internal/setup.js"; -import type { ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; -import { resolveTelegramAccount } from "./accounts.js"; + type OpenClawConfig, + patchChannelConfigForAccount, + setChannelDmPolicyWithAllowFrom, + setSetupChannelEnabled, + splitSetupEntries, +} from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; import { - createTelegramSetupWizardBase, parseTelegramAllowFromId, + promptTelegramAllowFromForAccount, + resolveTelegramAllowFromEntries, + TELEGRAM_TOKEN_HELP_LINES, + TELEGRAM_USER_ID_HELP_LINES, telegramSetupAdapter, } from "./setup-core.js"; -export const telegramSetupWizard: ChannelSetupWizard = createTelegramSetupWizardBase({ - inspectToken: ({ cfg, accountId }) => { - const resolved = resolveTelegramAccount({ cfg, accountId }); - const hasConfiguredBotToken = hasConfiguredSecretInput(resolved.config.botToken); - const hasConfiguredValue = hasConfiguredBotToken || Boolean(resolved.config.tokenFile?.trim()); - return { - accountConfigured: Boolean(resolved.token) || hasConfiguredValue, - hasConfiguredValue, - resolvedValue: resolved.token?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID - ? process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined - : undefined, - }; +const channel = "telegram" as const; + +const dmPolicy: ChannelSetupDmPolicy = { + label: "Telegram", + channel, + policyKey: "channels.telegram.dmPolicy", + allowFromKey: "channels.telegram.allowFrom", + getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptTelegramAllowFromForAccount, +}; + +export const telegramSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token", + configuredHint: "recommended · configured", + unconfiguredHint: "recommended · newcomer-friendly", + configuredScore: 1, + unconfiguredScore: 10, + resolveConfigured: ({ cfg }) => + listTelegramAccountIds(cfg).some((accountId) => { + const account = inspectTelegramAccount({ cfg, accountId }); + return account.configured; + }), }, -}); + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "Telegram bot token", + preferredEnvVar: "TELEGRAM_BOT_TOKEN", + helpTitle: "Telegram bot token", + helpLines: TELEGRAM_TOKEN_HELP_LINES, + envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", + keepPrompt: "Telegram token already configured. Keep it?", + inputPrompt: "Enter Telegram bot token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveTelegramAccount({ cfg, accountId }); + const hasConfiguredBotToken = hasConfiguredSecretInput(resolved.config.botToken); + const hasConfiguredValue = + hasConfiguredBotToken || Boolean(resolved.config.tokenFile?.trim()); + return { + accountConfigured: Boolean(resolved.token) || hasConfiguredValue, + hasConfiguredValue, + resolvedValue: resolved.token?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined + : undefined, + }; + }, + }, + ], + allowFrom: { + helpTitle: "Telegram user id", + helpLines: TELEGRAM_USER_ID_HELP_LINES, + credentialInputKey: "token", + message: "Telegram allowFrom (numeric sender id; @username resolves to id)", + placeholder: "@username", + invalidWithoutCredentialNote: + "Telegram token missing; use numeric sender ids (usernames require a bot token).", + parseInputs: splitSetupEntries, + parseId: parseTelegramAllowFromId, + resolveEntries: async ({ credentialValues, entries }) => + resolveTelegramAllowFromEntries({ + credentialValue: credentialValues.token, + entries, + }), + apply: async ({ cfg, accountId, allowFrom }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + dmPolicy, + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), +}; export { parseTelegramAllowFromId, telegramSetupAdapter }; diff --git a/extensions/telegram/src/status-issues.ts b/extensions/telegram/src/status-issues.ts index b970f533dd0..0178c0c7346 100644 --- a/extensions/telegram/src/status-issues.ts +++ b/extensions/telegram/src/status-issues.ts @@ -3,11 +3,11 @@ import { asString, isRecord, resolveEnabledConfiguredAccountId, -} from "../../../src/channels/plugins/status-issues/shared.js"; +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelAccountSnapshot, ChannelStatusIssue, -} from "../../../src/channels/plugins/types.js"; +} from "openclaw/plugin-sdk/channel-runtime"; type TelegramAccountStatus = { accountId?: unknown; diff --git a/extensions/telegram/src/status-reaction-variants.ts b/extensions/telegram/src/status-reaction-variants.ts index 6c5c80e9fd8..8c04a87554e 100644 --- a/extensions/telegram/src/status-reaction-variants.ts +++ b/extensions/telegram/src/status-reaction-variants.ts @@ -1,7 +1,4 @@ -import { - DEFAULT_EMOJIS, - type StatusReactionEmojis, -} from "../../../src/channels/status-reactions.js"; +import { DEFAULT_EMOJIS, type StatusReactionEmojis } from "openclaw/plugin-sdk/channel-runtime"; type StatusReactionEmojiKey = keyof Required; diff --git a/extensions/telegram/src/sticker-cache.ts b/extensions/telegram/src/sticker-cache.ts index e6cdfbd9015..18bfbbf4421 100644 --- a/extensions/telegram/src/sticker-cache.ts +++ b/extensions/telegram/src/sticker-cache.ts @@ -1,22 +1,19 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { resolveApiKeyForProvider } from "../../../src/agents/model-auth.js"; -import type { ModelCatalogEntry } from "../../../src/agents/model-catalog.js"; +import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/agent-runtime"; +import type { ModelCatalogEntry } from "openclaw/plugin-sdk/agent-runtime"; import { findModelInCatalog, loadModelCatalog, modelSupportsVision, -} from "../../../src/agents/model-catalog.js"; -import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { STATE_DIR } from "../../../src/config/paths.js"; -import { logVerbose } from "../../../src/globals.js"; -import { loadJsonFile, saveJsonFile } from "../../../src/infra/json-file.js"; -import { - AUTO_IMAGE_KEY_PROVIDERS, - DEFAULT_IMAGE_MODELS, -} from "../../../src/media-understanding/defaults.js"; -import { resolveAutoImageModel } from "../../../src/media-understanding/runner.js"; +} from "openclaw/plugin-sdk/agent-runtime"; +import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store"; +import { AUTO_IMAGE_KEY_PROVIDERS, DEFAULT_IMAGE_MODELS } from "openclaw/plugin-sdk/media-runtime"; +import { resolveAutoImageModel } from "openclaw/plugin-sdk/media-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { STATE_DIR } from "openclaw/plugin-sdk/state-paths"; const CACHE_FILE = path.join(STATE_DIR, "telegram", "sticker-cache.json"); const CACHE_VERSION = 1; @@ -146,12 +143,10 @@ export function getCacheStats(): { count: number; oldestAt?: string; newestAt?: const STICKER_DESCRIPTION_PROMPT = "Describe this sticker image in 1-2 sentences. Focus on what the sticker depicts (character, object, action, emotion). Be concise and objective."; -let imageRuntimePromise: Promise< - typeof import("../../../src/media-understanding/providers/image-runtime.js") -> | null = null; +let imageRuntimePromise: Promise | null = null; function loadImageRuntime() { - imageRuntimePromise ??= import("../../../src/media-understanding/providers/image-runtime.js"); + imageRuntimePromise ??= import("openclaw/plugin-sdk/media-runtime"); return imageRuntimePromise; } diff --git a/extensions/telegram/src/target-writeback.ts b/extensions/telegram/src/target-writeback.ts index 6423215ffa2..8e5bf197a23 100644 --- a/extensions/telegram/src/target-writeback.ts +++ b/extensions/telegram/src/target-writeback.ts @@ -1,7 +1,14 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { readConfigFileSnapshotForWrite, writeConfigFile } from "../../../src/config/config.js"; -import { loadCronStore, resolveCronStorePath, saveCronStore } from "../../../src/cron/store.js"; -import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + readConfigFileSnapshotForWrite, + writeConfigFile, +} from "openclaw/plugin-sdk/config-runtime"; +import { + loadCronStore, + resolveCronStorePath, + saveCronStore, +} from "openclaw/plugin-sdk/config-runtime"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { normalizeTelegramChatId, normalizeTelegramLookupTarget, diff --git a/extensions/telegram/src/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts index d10fef7f72c..aaf13e15561 100644 --- a/extensions/telegram/src/thread-bindings.ts +++ b/extensions/telegram/src/thread-bindings.ts @@ -1,19 +1,19 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveThreadBindingConversationIdFromBindingId } from "../../../src/channels/thread-binding-id.js"; -import { formatThreadBindingDurationLabel } from "../../../src/channels/thread-bindings-messages.js"; -import { resolveStateDir } from "../../../src/config/paths.js"; -import { logVerbose } from "../../../src/globals.js"; -import { writeJsonAtomic } from "../../../src/infra/json-files.js"; +import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime"; +import { formatThreadBindingDurationLabel } from "openclaw/plugin-sdk/channel-runtime"; import { registerSessionBindingAdapter, unregisterSessionBindingAdapter, type BindingTargetKind, type SessionBindingRecord, -} from "../../../src/infra/outbound/session-binding-service.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; -import { resolveGlobalSingleton } from "../../../src/shared/global-singleton.js"; +} from "openclaw/plugin-sdk/conversation-runtime"; +import { writeJsonAtomic } from "openclaw/plugin-sdk/infra-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; +import { resolveGlobalSingleton } from "openclaw/plugin-sdk/text-runtime"; const DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1000; const DEFAULT_THREAD_BINDING_MAX_AGE_MS = 0; diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts index d26d9657ca1..7a23a34ab12 100644 --- a/extensions/telegram/src/token.ts +++ b/extensions/telegram/src/token.ts @@ -1,9 +1,9 @@ -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 { tryReadSecretFileSync } from "../../../src/infra/secret-file.js"; -import type { TelegramAccountConfig } from "../../../src/plugin-sdk-internal/telegram.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; +import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import type { TelegramAccountConfig } from "openclaw/plugin-sdk/telegram"; export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none"; diff --git a/extensions/telegram/src/update-offset-store.ts b/extensions/telegram/src/update-offset-store.ts index 55b4e96ae23..395b5c1e450 100644 --- a/extensions/telegram/src/update-offset-store.ts +++ b/extensions/telegram/src/update-offset-store.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { resolveStateDir } from "../../../src/config/paths.js"; -import { writeJsonAtomic } from "../../../src/infra/json-files.js"; +import { writeJsonAtomic } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; const STORE_VERSION = 2; diff --git a/extensions/telegram/src/voice.ts b/extensions/telegram/src/voice.ts index 865bd82d72e..8a452471603 100644 --- a/extensions/telegram/src/voice.ts +++ b/extensions/telegram/src/voice.ts @@ -1,4 +1,4 @@ -import { isTelegramVoiceCompatibleAudio } from "../../../src/media/audio.js"; +import { isTelegramVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime"; export function resolveTelegramVoiceDecision(opts: { wantsVoice: boolean; diff --git a/extensions/telegram/src/webhook.ts b/extensions/telegram/src/webhook.ts index 39458ae036a..076bd12b279 100644 --- a/extensions/telegram/src/webhook.ts +++ b/extensions/telegram/src/webhook.ts @@ -1,19 +1,19 @@ import { timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; import { InputFile, webhookCallback } from "grammy"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { isDiagnosticsEnabled } from "../../../src/infra/diagnostic-events.js"; -import { formatErrorMessage } from "../../../src/infra/errors.js"; -import { readJsonBodyWithLimit } from "../../../src/infra/http-body.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { isDiagnosticsEnabled } from "openclaw/plugin-sdk/infra-runtime"; +import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime"; +import { readJsonBodyWithLimit } from "openclaw/plugin-sdk/infra-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env"; import { logWebhookError, logWebhookProcessed, logWebhookReceived, startDiagnosticHeartbeat, stopDiagnosticHeartbeat, -} from "../../../src/logging/diagnostic.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; -import { defaultRuntime } from "../../../src/runtime.js"; +} from "openclaw/plugin-sdk/text-runtime"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { createTelegramBot } from "./bot.js"; diff --git a/extensions/test-utils/directory.ts b/extensions/test-utils/directory.ts index 90d2ed445d3..b4edaa12ded 100644 --- a/extensions/test-utils/directory.ts +++ b/extensions/test-utils/directory.ts @@ -1,4 +1,4 @@ -import type { ChannelDirectoryAdapter } from "../../src/channels/plugins/types.js"; +import type { ChannelDirectoryAdapter } from "openclaw/plugin-sdk/channel-runtime"; export function createDirectoryTestRuntime() { return { diff --git a/extensions/test-utils/plugin-api.ts b/extensions/test-utils/plugin-api.ts index 82fe818fdec..2080359d961 100644 --- a/extensions/test-utils/plugin-api.ts +++ b/extensions/test-utils/plugin-api.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; type TestPluginApiInput = Partial & Pick; diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/extensions/test-utils/plugin-runtime-mock.ts index b7ca386028b..a5003620a59 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/extensions/test-utils/plugin-runtime-mock.ts @@ -1,7 +1,7 @@ +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "openclaw/plugin-sdk/agent-runtime"; 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 diff --git a/extensions/together/index.ts b/extensions/together/index.ts index a32031f0634..5f6dfb3e7c4 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -1,5 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyTogetherConfig, TOGETHER_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildTogetherProvider } from "./provider-catalog.js"; diff --git a/extensions/together/onboard.ts b/extensions/together/onboard.ts index a540401e01a..e18595ab21e 100644 --- a/extensions/together/onboard.ts +++ b/extensions/together/onboard.ts @@ -2,12 +2,12 @@ import { buildTogetherModelDefinition, TOGETHER_BASE_URL, TOGETHER_MODEL_CATALOG, -} from "../../src/agents/together-models.js"; +} from "openclaw/plugin-sdk/provider-models"; import { applyAgentDefaultModelPrimary, applyProviderConfigWithModelCatalog, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; export const TOGETHER_DEFAULT_MODEL_REF = "together/moonshotai/Kimi-K2.5"; diff --git a/extensions/together/provider-catalog.ts b/extensions/together/provider-catalog.ts index 3d902d3bb1a..45d3b5de130 100644 --- a/extensions/together/provider-catalog.ts +++ b/extensions/together/provider-catalog.ts @@ -1,9 +1,9 @@ import { buildTogetherModelDefinition, + type ModelProviderConfig, TOGETHER_BASE_URL, TOGETHER_MODEL_CATALOG, -} from "../../src/agents/together-models.js"; -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +} from "openclaw/plugin-sdk/provider-models"; export function buildTogetherProvider(): ModelProviderConfig { return { diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index 3958a05fd8b..490b741d989 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -136,7 +136,7 @@ export const twitchPlugin: ChannelPlugin = { accountId?: string | null; inputs: string[]; kind: ChannelResolveKind; - runtime: import("../../../src/runtime.js").RuntimeEnv; + runtime: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv; }): Promise => { const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID); diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index 92ff17e6df5..d25e8ffb9b8 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -1,5 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildVeniceProvider } from "./provider-catalog.js"; diff --git a/extensions/venice/onboard.ts b/extensions/venice/onboard.ts index fbd535d6264..23634a18540 100644 --- a/extensions/venice/onboard.ts +++ b/extensions/venice/onboard.ts @@ -3,12 +3,12 @@ import { VENICE_BASE_URL, VENICE_DEFAULT_MODEL_REF, VENICE_MODEL_CATALOG, -} from "../../src/agents/venice-models.js"; +} from "openclaw/plugin-sdk/provider-models"; import { applyAgentDefaultModelPrimary, applyProviderConfigWithModelCatalog, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; export { VENICE_DEFAULT_MODEL_REF }; diff --git a/extensions/venice/provider-catalog.ts b/extensions/venice/provider-catalog.ts index ec7087a08db..d207ab581b1 100644 --- a/extensions/venice/provider-catalog.ts +++ b/extensions/venice/provider-catalog.ts @@ -1,5 +1,8 @@ -import { discoverVeniceModels, VENICE_BASE_URL } from "../../src/agents/venice-models.js"; -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import { + discoverVeniceModels, + type ModelProviderConfig, + VENICE_BASE_URL, +} from "openclaw/plugin-sdk/provider-models"; export async function buildVeniceProvider(): Promise { const models = await discoverVeniceModels(); diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index ea7c734f310..1f126260321 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -1,5 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildVercelAiGatewayProvider } from "./provider-catalog.js"; diff --git a/extensions/vercel-ai-gateway/onboard.ts b/extensions/vercel-ai-gateway/onboard.ts index d65d7224781..5ca89c8ad33 100644 --- a/extensions/vercel-ai-gateway/onboard.ts +++ b/extensions/vercel-ai-gateway/onboard.ts @@ -1,5 +1,7 @@ -import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; +import { + applyAgentDefaultModelPrimary, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.6"; diff --git a/extensions/vercel-ai-gateway/provider-catalog.ts b/extensions/vercel-ai-gateway/provider-catalog.ts index 0e219264ab7..d3475efe9b9 100644 --- a/extensions/vercel-ai-gateway/provider-catalog.ts +++ b/extensions/vercel-ai-gateway/provider-catalog.ts @@ -1,8 +1,8 @@ import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL, -} from "../../src/agents/vercel-ai-gateway.js"; -import type { ModelProviderConfig } from "../../src/config/types.models.js"; + type ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-models"; export async function buildVercelAiGatewayProvider(): Promise { return { diff --git a/extensions/vllm/index.ts b/extensions/vllm/index.ts index 938fb78c9bd..24805e700a6 100644 --- a/extensions/vllm/index.ts +++ b/extensions/vllm/index.ts @@ -1,14 +1,14 @@ -import { - emptyPluginConfigSchema, - 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"; +} from "openclaw/plugin-sdk/agent-runtime"; +import { + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderAuthMethodNonInteractiveContext, +} from "openclaw/plugin-sdk/core"; const PROVIDER_ID = "vllm"; diff --git a/extensions/volcengine/index.ts b/extensions/volcengine/index.ts index f9e3fb72010..975bcce610d 100644 --- a/extensions/volcengine/index.ts +++ b/extensions/volcengine/index.ts @@ -1,7 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { ensureModelAllowlistEntry } from "../../src/commands/model-allowlist.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; -import { buildPairedProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard"; import { buildDoubaoCodingProvider, buildDoubaoProvider } from "./provider-catalog.js"; const PROVIDER_ID = "volcengine"; @@ -46,15 +45,18 @@ const volcenginePlugin = { ], catalog: { order: "paired", - run: (ctx) => - buildPairedProviderApiKeyCatalog({ - ctx, - providerId: PROVIDER_ID, - buildProviders: () => ({ - volcengine: buildDoubaoProvider(), - "volcengine-plan": buildDoubaoCodingProvider(), - }), - }), + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + providers: { + volcengine: { ...buildDoubaoProvider(), apiKey }, + "volcengine-plan": { ...buildDoubaoCodingProvider(), apiKey }, + }, + }; + }, }, }); }, diff --git a/extensions/volcengine/provider-catalog.ts b/extensions/volcengine/provider-catalog.ts index ef57e0a86e7..f01a3079bcc 100644 --- a/extensions/volcengine/provider-catalog.ts +++ b/extensions/volcengine/provider-catalog.ts @@ -4,8 +4,8 @@ import { 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"; + type ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-models"; export function buildDoubaoProvider(): ModelProviderConfig { return { diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index 1d17404a6a2..d2a4e277846 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -1,19 +1,15 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveOAuthDir } from "../../../src/config/paths.js"; import { - type OpenClawConfig, createAccountListHelpers, DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveAccountEntry, resolveUserPath, -} from "../../../src/plugin-sdk-internal/accounts.js"; -import type { - DmPolicy, - GroupPolicy, - WhatsAppAccountConfig, -} from "../../../src/plugin-sdk-internal/whatsapp.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/account-resolution"; +import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths"; +import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "openclaw/plugin-sdk/whatsapp"; import { hasWebCredsSync } from "./auth-store.js"; export type ResolvedWhatsAppAccount = { diff --git a/extensions/whatsapp/src/active-listener.ts b/extensions/whatsapp/src/active-listener.ts index fc8f11fe20e..71b6086f3a0 100644 --- a/extensions/whatsapp/src/active-listener.ts +++ b/extensions/whatsapp/src/active-listener.ts @@ -1,6 +1,6 @@ -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import type { PollInput } from "../../../src/polls.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; +import type { PollInput } from "openclaw/plugin-sdk/media-runtime"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; export type ActiveWebSendOptions = { gifPlayback?: boolean; diff --git a/extensions/whatsapp/src/agent-tools-login.ts b/extensions/whatsapp/src/agent-tools-login.ts index a1ac87a3976..9343e83d21a 100644 --- a/extensions/whatsapp/src/agent-tools-login.ts +++ b/extensions/whatsapp/src/agent-tools-login.ts @@ -1,5 +1,5 @@ import { Type } from "@sinclair/typebox"; -import type { ChannelAgentTool } from "../../../src/channels/plugins/types.js"; +import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-runtime"; export function createWhatsAppLoginTool(): ChannelAgentTool { return { diff --git a/extensions/whatsapp/src/auth-store.ts b/extensions/whatsapp/src/auth-store.ts index 636c114676f..991be6dff7d 100644 --- a/extensions/whatsapp/src/auth-store.ts +++ b/extensions/whatsapp/src/auth-store.ts @@ -1,14 +1,14 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import { resolveOAuthDir } from "../../../src/config/paths.js"; -import { info, success } from "../../../src/globals.js"; -import { getChildLogger } from "../../../src/logging.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js"; -import type { WebChannel } from "../../../src/utils.js"; -import { jidToE164, resolveUserPath } from "../../../src/utils.js"; +import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; +import { info, success } from "openclaw/plugin-sdk/runtime-env"; +import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; +import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths"; +import type { WebChannel } from "openclaw/plugin-sdk/text-runtime"; +import { jidToE164, resolveUserPath } from "openclaw/plugin-sdk/text-runtime"; export function resolveDefaultWebAuthDir(): string { return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID); diff --git a/extensions/whatsapp/src/auto-reply.impl.ts b/extensions/whatsapp/src/auto-reply.impl.ts index 57feff1ab4d..e936c63e732 100644 --- a/extensions/whatsapp/src/auto-reply.impl.ts +++ b/extensions/whatsapp/src/auto-reply.impl.ts @@ -1,5 +1,5 @@ -export { HEARTBEAT_PROMPT, stripHeartbeatToken } from "../../../src/auto-reply/heartbeat.js"; -export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js"; +export { HEARTBEAT_PROMPT, stripHeartbeatToken } from "openclaw/plugin-sdk/reply-runtime"; +export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "openclaw/plugin-sdk/reply-runtime"; export { DEFAULT_WEB_MEDIA_BYTES } from "./auto-reply/constants.js"; export { resolveHeartbeatRecipients, runWebHeartbeatOnce } from "./auto-reply/heartbeat-runner.js"; diff --git a/extensions/whatsapp/src/auto-reply.test-harness.ts b/extensions/whatsapp/src/auto-reply.test-harness.ts index dfbcf447fa9..f3707f87679 100644 --- a/extensions/whatsapp/src/auto-reply.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.test-harness.ts @@ -2,10 +2,10 @@ import "./test-helpers.js"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import * as ssrf from "openclaw/plugin-sdk/infra-runtime"; +import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; +import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; -import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; -import * as ssrf from "../../../src/infra/net/ssrf.js"; -import { resetLogger, setLoggerOverride } from "../../../src/logging.js"; import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js"; import { resetBaileysMocks as _resetBaileysMocks, @@ -29,7 +29,7 @@ type MockWebListener = { export const TEST_NET_IP = "203.0.113.10"; -vi.mock("../../../src/agents/pi-embedded.js", () => ({ +vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.ts index 6fb4ce39143..6d9d8b541ae 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.ts @@ -1,10 +1,10 @@ -import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../../src/auto-reply/chunk.js"; -import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; -import type { MarkdownTableMode } from "../../../../src/config/types.base.js"; -import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; -import { convertMarkdownTables } from "../../../../src/markdown/tables.js"; -import { markdownToWhatsApp } from "../../../../src/markdown/whatsapp.js"; -import { sleep } from "../../../../src/utils.js"; +import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; +import { markdownToWhatsApp } from "openclaw/plugin-sdk/text-runtime"; +import { sleep } from "openclaw/plugin-sdk/text-runtime"; import { loadWebMedia } from "../media.js"; import { newConnectionId } from "../reconnect.js"; import { formatError } from "../session.js"; diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts index 0b423a3f116..7aa35705f43 100644 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts @@ -1,28 +1,25 @@ -import { appendCronStyleCurrentTimeLine } from "../../../../src/agents/current-time.js"; -import { resolveHeartbeatReplyPayload } from "../../../../src/auto-reply/heartbeat-reply-payload.js"; -import { - DEFAULT_HEARTBEAT_ACK_MAX_CHARS, - resolveHeartbeatPrompt, - stripHeartbeatToken, -} from "../../../../src/auto-reply/heartbeat.js"; -import { getReplyFromConfig } from "../../../../src/auto-reply/reply.js"; -import { HEARTBEAT_TOKEN } from "../../../../src/auto-reply/tokens.js"; -import { resolveWhatsAppHeartbeatRecipients } from "../../../../src/channels/plugins/whatsapp-heartbeat.js"; -import { loadConfig } from "../../../../src/config/config.js"; +import { appendCronStyleCurrentTimeLine } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveWhatsAppHeartbeatRecipients } from "openclaw/plugin-sdk/channel-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveSessionKey, resolveStorePath, updateSessionStore, -} from "../../../../src/config/sessions.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { emitHeartbeatEvent, resolveIndicatorType } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveHeartbeatVisibility } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveHeartbeatReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { - emitHeartbeatEvent, - resolveIndicatorType, -} from "../../../../src/infra/heartbeat-events.js"; -import { resolveHeartbeatVisibility } from "../../../../src/infra/heartbeat-visibility.js"; -import { getChildLogger } from "../../../../src/logging.js"; -import { redactIdentifier } from "../../../../src/logging/redact-identifier.js"; -import { normalizeMainKey } from "../../../../src/routing/session-key.js"; + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + resolveHeartbeatPrompt, + stripHeartbeatToken, +} from "openclaw/plugin-sdk/reply-runtime"; +import { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime"; +import { HEARTBEAT_TOKEN } from "openclaw/plugin-sdk/reply-runtime"; +import { normalizeMainKey } from "openclaw/plugin-sdk/routing"; +import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; +import { redactIdentifier } from "openclaw/plugin-sdk/text-runtime"; import { newConnectionId } from "../reconnect.js"; import { sendMessageWhatsApp } from "../send.js"; import { formatError } from "../session.js"; diff --git a/extensions/whatsapp/src/auto-reply/loggers.ts b/extensions/whatsapp/src/auto-reply/loggers.ts index 71575671b2e..1201a412a59 100644 --- a/extensions/whatsapp/src/auto-reply/loggers.ts +++ b/extensions/whatsapp/src/auto-reply/loggers.ts @@ -1,4 +1,4 @@ -import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; export const whatsappLog = createSubsystemLogger("gateway/channels/whatsapp"); export const whatsappInboundLog = whatsappLog.child("inbound"); diff --git a/extensions/whatsapp/src/auto-reply/mentions.ts b/extensions/whatsapp/src/auto-reply/mentions.ts index 3891810c617..ad42c814c26 100644 --- a/extensions/whatsapp/src/auto-reply/mentions.ts +++ b/extensions/whatsapp/src/auto-reply/mentions.ts @@ -1,9 +1,6 @@ -import { - buildMentionRegexes, - normalizeMentionText, -} from "../../../../src/auto-reply/reply/mentions.js"; -import type { loadConfig } from "../../../../src/config/config.js"; -import { isSelfChatMode, jidToE164, normalizeE164 } from "../../../../src/utils.js"; +import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { buildMentionRegexes, normalizeMentionText } from "openclaw/plugin-sdk/reply-runtime"; +import { isSelfChatMode, jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import type { WebInboundMsg } from "./types.js"; export type MentionConfig = { diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index 1222c69b71a..2f83e65079a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -1,18 +1,18 @@ -import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js"; -import { resolveInboundDebounceMs } from "../../../../src/auto-reply/inbound-debounce.js"; -import { getReplyFromConfig } from "../../../../src/auto-reply/reply.js"; -import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../../../src/auto-reply/reply/history.js"; -import { formatCliCommand } from "../../../../src/cli/command-format.js"; -import { waitForever } from "../../../../src/cli/wait.js"; -import { loadConfig } from "../../../../src/config/config.js"; -import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js"; -import { logVerbose } from "../../../../src/globals.js"; -import { formatDurationPrecise } from "../../../../src/infra/format-time/format-duration.ts"; -import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; -import { registerUnhandledRejectionHandler } from "../../../../src/infra/unhandled-rejections.js"; -import { getChildLogger } from "../../../../src/logging.js"; -import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; -import { defaultRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; +import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; +import { waitForever } from "openclaw/plugin-sdk/cli-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; +import { formatDurationPrecise } from "openclaw/plugin-sdk/infra-runtime"; +import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; +import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/reply-runtime"; +import { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime"; +import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env"; +import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; +import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "../accounts.js"; import { setActiveWebListener } from "../active-listener.js"; import { monitorWebInbox } from "../inbound.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts index c5a5d149ab7..126c485ec6f 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts @@ -1,6 +1,6 @@ -import { shouldAckReactionForWhatsApp } from "../../../../../src/channels/ack-reactions.js"; -import type { loadConfig } from "../../../../../src/config/config.js"; -import { logVerbose } from "../../../../../src/globals.js"; +import { shouldAckReactionForWhatsApp } from "openclaw/plugin-sdk/channel-runtime"; +import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { sendReactionWhatsApp } from "../../send.js"; import { formatError } from "../../session.js"; import type { WebInboundMsg } from "../types.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts b/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts index b00ba7aff9b..b2dc74cffe5 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts @@ -1,14 +1,11 @@ -import type { loadConfig } from "../../../../../src/config/config.js"; -import type { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; -import { - buildAgentSessionKey, - deriveLastRoutePolicy, -} from "../../../../../src/routing/resolve-route.js"; +import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { buildAgentSessionKey, deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; import { buildAgentMainSessionKey, DEFAULT_MAIN_KEY, normalizeAgentId, -} from "../../../../../src/routing/session-key.js"; +} from "openclaw/plugin-sdk/routing"; import { formatError } from "../../session.js"; import { whatsappInboundLog } from "../loggers.js"; import type { WebInboundMsg } from "../types.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts b/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts index 60b15f5b3c6..745e62fa17a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts @@ -1,14 +1,14 @@ -import { normalizeGroupActivation } from "../../../../../src/auto-reply/group-activation.js"; -import type { loadConfig } from "../../../../../src/config/config.js"; +import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, -} from "../../../../../src/config/group-policy.js"; +} from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveGroupSessionKey, resolveStorePath, -} from "../../../../../src/config/sessions.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { normalizeGroupActivation } from "openclaw/plugin-sdk/reply-runtime"; export function resolveGroupPolicyFor(cfg: ReturnType, conversationId: string) { const groupId = resolveGroupSessionKey({ diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts index 418d5ebee83..847e5e3182f 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts @@ -1,9 +1,9 @@ -import { hasControlCommand } from "../../../../../src/auto-reply/command-detection.js"; -import { parseActivationCommand } from "../../../../../src/auto-reply/group-activation.js"; -import { recordPendingHistoryEntryIfEnabled } from "../../../../../src/auto-reply/reply/history.js"; -import { resolveMentionGating } from "../../../../../src/channels/mention-gating.js"; -import type { loadConfig } from "../../../../../src/config/config.js"; -import { normalizeE164 } from "../../../../../src/utils.js"; +import { resolveMentionGating } from "openclaw/plugin-sdk/channel-runtime"; +import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime"; +import { parseActivationCommand } from "openclaw/plugin-sdk/reply-runtime"; +import { recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-runtime"; +import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import type { MentionConfig } from "../mentions.js"; import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js"; import type { WebInboundMsg } from "../types.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-members.ts b/extensions/whatsapp/src/auto-reply/monitor/group-members.ts index fc2d541bcf5..a037dcfb38b 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-members.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-members.ts @@ -1,4 +1,4 @@ -import { normalizeE164 } from "../../../../../src/utils.js"; +import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; function appendNormalizedUnique(entries: Iterable, seen: Set, ordered: string[]) { for (const entry of entries) { diff --git a/extensions/whatsapp/src/auto-reply/monitor/last-route.ts b/extensions/whatsapp/src/auto-reply/monitor/last-route.ts index 9fbe17d104d..915db0ba761 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/last-route.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/last-route.ts @@ -1,6 +1,6 @@ -import type { MsgContext } from "../../../../../src/auto-reply/templating.js"; -import type { loadConfig } from "../../../../../src/config/config.js"; -import { resolveStorePath, updateLastRoute } from "../../../../../src/config/sessions.js"; +import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/config-runtime"; +import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { formatError } from "../../session.js"; export function trackBackgroundTask( diff --git a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts index 299d5868bf8..b9494f0325c 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts @@ -1,9 +1,9 @@ -import { resolveMessagePrefix } from "../../../../../src/agents/identity.js"; +import { resolveMessagePrefix } from "openclaw/plugin-sdk/agent-runtime"; +import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { formatInboundEnvelope, type EnvelopeFormatOptions, -} from "../../../../../src/auto-reply/envelope.js"; -import type { loadConfig } from "../../../../../src/config/config.js"; +} from "openclaw/plugin-sdk/reply-runtime"; import type { WebInboundMsg } from "../types.js"; export function formatReplyContext(msg: WebInboundMsg) { diff --git a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts index caa519f5cf0..fe91ffff547 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts @@ -1,10 +1,10 @@ -import type { getReplyFromConfig } from "../../../../../src/auto-reply/reply.js"; -import type { MsgContext } from "../../../../../src/auto-reply/templating.js"; -import { loadConfig } from "../../../../../src/config/config.js"; -import { logVerbose } from "../../../../../src/globals.js"; -import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; -import { buildGroupHistoryKey } from "../../../../../src/routing/session-key.js"; -import { normalizeE164 } from "../../../../../src/utils.js"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime"; +import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { buildGroupHistoryKey } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import type { MentionConfig } from "../mentions.js"; import type { WebInboundMsg } from "../types.js"; import { maybeBroadcastMessage } from "./broadcast.js"; @@ -26,7 +26,7 @@ export function createWebOnMessageHandler(params: { echoTracker: EchoTracker; backgroundTasks: Set>; replyResolver: typeof getReplyFromConfig; - replyLogger: ReturnType<(typeof import("../../../../../src/logging.js"))["getChildLogger"]>; + replyLogger: ReturnType<(typeof import("openclaw/plugin-sdk/runtime-env"))["getChildLogger"]>; baseMentionConfig: MentionConfig; account: { authDir?: string; accountId?: string }; }) { diff --git a/extensions/whatsapp/src/auto-reply/monitor/peer.ts b/extensions/whatsapp/src/auto-reply/monitor/peer.ts index 7795ac7c4d1..daaa5a50f01 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/peer.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/peer.ts @@ -1,4 +1,4 @@ -import { jidToE164, normalizeE164 } from "../../../../../src/utils.js"; +import { jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import type { WebInboundMsg } from "../types.js"; export function resolvePeerId(msg: WebInboundMsg) { diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index 094e4570bdb..beaa564fe28 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -1,34 +1,34 @@ -import { resolveIdentityNamePrefix } from "../../../../../src/agents/identity.js"; -import { resolveChunkMode, resolveTextChunkLimit } from "../../../../../src/auto-reply/chunk.js"; -import { shouldComputeCommandAuthorized } from "../../../../../src/auto-reply/command-detection.js"; -import { formatInboundEnvelope } from "../../../../../src/auto-reply/envelope.js"; -import type { getReplyFromConfig } from "../../../../../src/auto-reply/reply.js"; +import { resolveIdentityNamePrefix } from "openclaw/plugin-sdk/agent-runtime"; +import { toLocationContext } from "openclaw/plugin-sdk/channel-runtime"; +import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveInboundSessionEnvelopeContext } from "openclaw/plugin-sdk/channel-runtime"; +import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { recordSessionMetaFromInbound } from "openclaw/plugin-sdk/config-runtime"; +import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; +import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/reply-runtime"; +import { formatInboundEnvelope } from "openclaw/plugin-sdk/reply-runtime"; +import type { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime"; import { buildHistoryContextFromEntries, type HistoryEntry, -} from "../../../../../src/auto-reply/reply/history.js"; -import { finalizeInboundContext } from "../../../../../src/auto-reply/reply/inbound-context.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../../../../../src/auto-reply/reply/provider-dispatcher.js"; -import type { ReplyPayload } from "../../../../../src/auto-reply/types.js"; -import { toLocationContext } from "../../../../../src/channels/location.js"; -import { createReplyPrefixOptions } from "../../../../../src/channels/reply-prefix.js"; -import { resolveInboundSessionEnvelopeContext } from "../../../../../src/channels/session-envelope.js"; -import type { loadConfig } from "../../../../../src/config/config.js"; -import { resolveMarkdownTableMode } from "../../../../../src/config/markdown-tables.js"; -import { recordSessionMetaFromInbound } from "../../../../../src/config/sessions.js"; -import { logVerbose, shouldLogVerbose } from "../../../../../src/globals.js"; -import type { getChildLogger } from "../../../../../src/logging.js"; -import { getAgentScopedMediaLocalRoots } from "../../../../../src/media/local-roots.js"; +} from "openclaw/plugin-sdk/reply-runtime"; +import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; +import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { resolveInboundLastRouteSessionKey, type resolveAgentRoute, -} from "../../../../../src/routing/resolve-route.js"; +} from "openclaw/plugin-sdk/routing"; +import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import type { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import { readStoreAllowFromForDmPolicy, resolvePinnedMainDmOwnerFromAllowlist, resolveDmGroupAccessWithCommandGate, -} from "../../../../../src/security/dm-policy-shared.js"; -import { jidToE164, normalizeE164 } from "../../../../../src/utils.js"; +} from "openclaw/plugin-sdk/security-runtime"; +import { jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { resolveWhatsAppAccount } from "../../accounts.js"; import { newConnectionId } from "../../reconnect.js"; import { formatError } from "../../session.js"; diff --git a/extensions/whatsapp/src/auto-reply/session-snapshot.ts b/extensions/whatsapp/src/auto-reply/session-snapshot.ts index 53b7e3ae615..ff4899d0d52 100644 --- a/extensions/whatsapp/src/auto-reply/session-snapshot.ts +++ b/extensions/whatsapp/src/auto-reply/session-snapshot.ts @@ -1,4 +1,4 @@ -import type { loadConfig } from "../../../../src/config/config.js"; +import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { evaluateSessionFreshness, loadSessionStore, @@ -8,8 +8,8 @@ import { resolveSessionResetType, resolveSessionKey, resolveStorePath, -} from "../../../../src/config/sessions.js"; -import { normalizeMainKey } from "../../../../src/routing/session-key.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { normalizeMainKey } from "openclaw/plugin-sdk/routing"; export function getSessionSnapshot( cfg: ReturnType, diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index 919a75c1a8c..6cf2a75d1ce 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -1,21 +1,150 @@ -import { type ChannelPlugin } from "openclaw/plugin-sdk/whatsapp"; -import { type ResolvedWhatsAppAccount } from "./accounts.js"; +import { + buildAccountScopedDmSecurityPolicy, + buildChannelConfigSchema, + collectAllowlistProviderGroupPolicyWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, + DEFAULT_ACCOUNT_ID, + formatWhatsAppConfigAllowFromEntries, + getChatChannelMeta, + normalizeE164, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, + WhatsAppConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/whatsapp"; +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAccount, + type ResolvedWhatsAppAccount, +} from "./accounts.js"; import { webAuthExists } from "./auth-store.js"; +import { whatsappSetupWizardProxy } from "./plugin-shared.js"; import { whatsappSetupAdapter } from "./setup-core.js"; -import { createWhatsAppPluginBase, createWhatsAppSetupWizardProxy } from "./shared.js"; - -async function loadWhatsAppChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const whatsappSetupWizardProxy = createWhatsAppSetupWizardProxy(async () => ({ - whatsappSetupWizard: (await loadWhatsAppChannelRuntime()).whatsappSetupWizard, -})); export const whatsappSetupPlugin: ChannelPlugin = { - ...createWhatsAppPluginBase({ - setupWizard: whatsappSetupWizardProxy, - setup: whatsappSetupAdapter, + id: "whatsapp", + meta: { + ...getChatChannelMeta("whatsapp"), + showConfigured: false, + quickstartAllowFrom: true, + forceAccountBinding: true, + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: whatsappSetupWizardProxy, + capabilities: { + chatTypes: ["direct", "group"], + polls: true, + reactions: true, + media: true, + }, + reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, + gatewayMethods: ["web.login.start", "web.login.wait"], + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), + config: { + listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + const existing = accounts[accountKey] ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: { + ...accounts, + [accountKey]: { + ...existing, + enabled, + }, + }, + }, + }, + }; + }, + deleteAccount: ({ cfg, accountId }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + delete accounts[accountKey]; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: Object.keys(accounts).length ? accounts : undefined, + }, + }, + }; + }, + isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, + disabledReason: () => "disabled", isConfigured: async (account) => await webAuthExists(account.authDir), - }), + unconfiguredReason: () => "not linked", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.authDir), + linked: Boolean(account.authDir), + dmPolicy: account.dmPolicy, + allowFrom: account.allowFrom, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "whatsapp", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.dmPolicy, + allowFrom: account.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw), + }), + collectWarnings: ({ account, cfg }) => { + const groupAllowlistConfigured = + Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; + return collectAllowlistProviderGroupPolicyWarnings({ + cfg, + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + configuredGroupPolicy: account.groupPolicy, + collect: (groupPolicy) => + collectOpenGroupPolicyRouteAllowlistWarnings({ + groupPolicy, + routeAllowlistConfigured: groupAllowlistConfigured, + restrictSenders: { + surface: "WhatsApp groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "WhatsApp groups", + routeAllowlistPath: "channels.whatsapp.groups", + routeScope: "group", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + }), + }); + }, + }, + setup: whatsappSetupAdapter, + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, }; diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 6fe1663e55f..4701c80070b 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,34 +1,45 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat"; +import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { + buildAccountScopedDmSecurityPolicy, + buildChannelConfigSchema, + collectAllowlistProviderGroupPolicyWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, createActionGate, createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, + getChatChannelMeta, listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, + normalizeE164, formatWhatsAppConfigAllowFromEntries, readStringParam, resolveWhatsAppOutboundTarget, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupToolPolicy, resolveWhatsAppHeartbeatRecipients, resolveWhatsAppMentionStripRegexes, + WhatsAppConfigSchema, type ChannelMessageActionName, type ChannelPlugin, } from "openclaw/plugin-sdk/whatsapp"; -import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../../src/whatsapp/normalize.js"; +import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "openclaw/plugin-sdk/whatsapp"; // WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) -import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAccount, + type ResolvedWhatsAppAccount, +} from "./accounts.js"; import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; +import { whatsappSetupWizardProxy } from "./plugin-shared.js"; import { getWhatsAppRuntime } from "./runtime.js"; import { whatsappSetupAdapter } from "./setup-core.js"; -import { - createWhatsAppPluginBase, - createWhatsAppSetupWizardProxy, - WHATSAPP_CHANNEL, -} from "./shared.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; -async function loadWhatsAppChannelRuntime() { - return await import("./channel.runtime.js"); -} +const meta = getChatChannelMeta("whatsapp"); function normalizeWhatsAppPayloadText(text: string | undefined): string { return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); @@ -45,21 +56,87 @@ function parseWhatsAppExplicitTarget(raw: string) { }; } -const whatsappSetupWizardProxy = createWhatsAppSetupWizardProxy(async () => ({ - whatsappSetupWizard: (await loadWhatsAppChannelRuntime()).whatsappSetupWizard, -})); - export const whatsappPlugin: ChannelPlugin = { - ...createWhatsAppPluginBase({ - setupWizard: whatsappSetupWizardProxy, - setup: whatsappSetupAdapter, - isConfigured: async (account) => - await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir), - }), + id: "whatsapp", + meta: { + ...meta, + showConfigured: false, + quickstartAllowFrom: true, + forceAccountBinding: true, + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: whatsappSetupWizardProxy, agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()], pairing: { idLabel: "whatsappSenderId", }, + capabilities: { + chatTypes: ["direct", "group"], + polls: true, + reactions: true, + media: true, + }, + reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, + gatewayMethods: ["web.login.start", "web.login.wait"], + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), + config: { + listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + const existing = accounts[accountKey] ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: { + ...accounts, + [accountKey]: { + ...existing, + enabled, + }, + }, + }, + }, + }; + }, + deleteAccount: ({ cfg, accountId }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + delete accounts[accountKey]; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: Object.keys(accounts).length ? accounts : undefined, + }, + }, + }; + }, + isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, + disabledReason: () => "disabled", + isConfigured: async (account) => + await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir), + unconfiguredReason: () => "not linked", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.authDir), + linked: Boolean(account.authDir), + dmPolicy: account.dmPolicy, + allowFrom: account.allowFrom, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), + }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => { @@ -80,6 +157,53 @@ export const whatsappPlugin: ChannelPlugin = { }), }), }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + return buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "whatsapp", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.dmPolicy, + allowFrom: account.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw), + }); + }, + collectWarnings: ({ account, cfg }) => { + const groupAllowlistConfigured = + Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; + return collectAllowlistProviderGroupPolicyWarnings({ + cfg, + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + configuredGroupPolicy: account.groupPolicy, + collect: (groupPolicy) => + collectOpenGroupPolicyRouteAllowlistWarnings({ + groupPolicy, + routeAllowlistConfigured: groupAllowlistConfigured, + restrictSenders: { + surface: "WhatsApp groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "WhatsApp groups", + routeAllowlistPath: "channels.whatsapp.groups", + routeScope: "group", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + }), + }); + }, + }, + setup: whatsappSetupAdapter, + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, mentions: { stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx), }, @@ -132,7 +256,7 @@ export const whatsappPlugin: ChannelPlugin = { supportsAction: ({ action }) => action === "react", handleAction: async ({ action, params, cfg, accountId }) => { if (action !== "react") { - throw new Error(`Action ${action} is not supported for provider ${WHATSAPP_CHANNEL}.`); + throw new Error(`Action ${action} is not supported for provider ${meta.id}.`); } const messageId = readStringParam(params, "messageId", { required: true, diff --git a/extensions/whatsapp/src/inbound/access-control.test-harness.ts b/extensions/whatsapp/src/inbound/access-control.test-harness.ts index a8bf7a9df19..495615a3cbb 100644 --- a/extensions/whatsapp/src/inbound/access-control.test-harness.ts +++ b/extensions/whatsapp/src/inbound/access-control.test-harness.ts @@ -33,15 +33,15 @@ export function setupAccessControlTestHarness(): void { }); } -vi.mock("../../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => config, }; }); -vi.mock("../../../../src/pairing/pairing-store.js", () => ({ +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), })); diff --git a/extensions/whatsapp/src/inbound/access-control.ts b/extensions/whatsapp/src/inbound/access-control.ts index ee81e119392..2c57abe8bbf 100644 --- a/extensions/whatsapp/src/inbound/access-control.ts +++ b/extensions/whatsapp/src/inbound/access-control.ts @@ -1,17 +1,17 @@ -import { loadConfig } from "../../../../src/config/config.js"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, -} from "../../../../src/config/runtime-group-policy.js"; -import { logVerbose } from "../../../../src/globals.js"; -import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; +} from "openclaw/plugin-sdk/config-runtime"; +import { issuePairingChallenge } from "openclaw/plugin-sdk/conversation-runtime"; +import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, -} from "../../../../src/security/dm-policy-shared.js"; -import { isSelfChatMode, normalizeE164 } from "../../../../src/utils.js"; +} from "openclaw/plugin-sdk/security-runtime"; +import { isSelfChatMode, normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { resolveWhatsAppAccount } from "../accounts.js"; export type InboundAccessControlResult = { diff --git a/extensions/whatsapp/src/inbound/dedupe.ts b/extensions/whatsapp/src/inbound/dedupe.ts index 9d20a25b8c4..cfc74185519 100644 --- a/extensions/whatsapp/src/inbound/dedupe.ts +++ b/extensions/whatsapp/src/inbound/dedupe.ts @@ -1,4 +1,4 @@ -import { createDedupeCache } from "../../../../src/infra/dedupe.js"; +import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime"; const RECENT_WEB_MESSAGE_TTL_MS = 20 * 60_000; const RECENT_WEB_MESSAGE_MAX = 5000; diff --git a/extensions/whatsapp/src/inbound/extract.ts b/extensions/whatsapp/src/inbound/extract.ts index a34937c9793..9fa663847a6 100644 --- a/extensions/whatsapp/src/inbound/extract.ts +++ b/extensions/whatsapp/src/inbound/extract.ts @@ -4,9 +4,9 @@ import { getContentType, normalizeMessageContent, } from "@whiskeysockets/baileys"; -import { formatLocationText, type NormalizedLocation } from "../../../../src/channels/location.js"; -import { logVerbose } from "../../../../src/globals.js"; -import { jidToE164 } from "../../../../src/utils.js"; +import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { jidToE164 } from "openclaw/plugin-sdk/text-runtime"; import { parseVcard } from "../vcard.js"; function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined { diff --git a/extensions/whatsapp/src/inbound/media.ts b/extensions/whatsapp/src/inbound/media.ts index 9f2fe70698a..128b4d945d5 100644 --- a/extensions/whatsapp/src/inbound/media.ts +++ b/extensions/whatsapp/src/inbound/media.ts @@ -1,6 +1,6 @@ import type { proto, WAMessage } from "@whiskeysockets/baileys"; import { downloadMediaMessage, normalizeMessageContent } from "@whiskeysockets/baileys"; -import { logVerbose } from "../../../../src/globals.js"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { createWaSocket } from "../session.js"; function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined { diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 5337c5d6a43..35669bc1b49 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -1,13 +1,13 @@ import type { AnyMessageContent, proto, WAMessage } from "@whiskeysockets/baileys"; import { DisconnectReason, isJidGroup } from "@whiskeysockets/baileys"; -import { createInboundDebouncer } from "../../../../src/auto-reply/inbound-debounce.js"; -import { formatLocationText } from "../../../../src/channels/location.js"; -import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; -import { recordChannelActivity } from "../../../../src/infra/channel-activity.js"; -import { getChildLogger } from "../../../../src/logging/logger.js"; -import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; -import { saveMediaBuffer } from "../../../../src/media/store.js"; -import { jidToE164, resolveJidToE164 } from "../../../../src/utils.js"; +import { formatLocationText } from "openclaw/plugin-sdk/channel-runtime"; +import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; +import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; +import { createInboundDebouncer } from "openclaw/plugin-sdk/reply-runtime"; +import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { getChildLogger } from "openclaw/plugin-sdk/text-runtime"; +import { jidToE164, resolveJidToE164 } from "openclaw/plugin-sdk/text-runtime"; import { createWaSocket, getStatusCode, waitForWaConnection } from "../session.js"; import { checkInboundAccessControl } from "./access-control.js"; import { isRecentInboundMessage } from "./dedupe.js"; diff --git a/extensions/whatsapp/src/inbound/send-api.ts b/extensions/whatsapp/src/inbound/send-api.ts index a5619383415..bb0761431f7 100644 --- a/extensions/whatsapp/src/inbound/send-api.ts +++ b/extensions/whatsapp/src/inbound/send-api.ts @@ -1,6 +1,6 @@ import type { AnyMessageContent, WAPresence } from "@whiskeysockets/baileys"; -import { recordChannelActivity } from "../../../../src/infra/channel-activity.js"; -import { toWhatsappJid } from "../../../../src/utils.js"; +import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; +import { toWhatsappJid } from "openclaw/plugin-sdk/text-runtime"; import type { ActiveWebSendOptions } from "../active-listener.js"; function recordWhatsAppOutbound(accountId: string) { diff --git a/extensions/whatsapp/src/inbound/types.ts b/extensions/whatsapp/src/inbound/types.ts index c9c97810bad..42e4b5121d1 100644 --- a/extensions/whatsapp/src/inbound/types.ts +++ b/extensions/whatsapp/src/inbound/types.ts @@ -1,5 +1,5 @@ import type { AnyMessageContent } from "@whiskeysockets/baileys"; -import type { NormalizedLocation } from "../../../../src/channels/location.js"; +import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime"; export type WebListenerCloseReason = { status?: number; diff --git a/extensions/whatsapp/src/login-qr.ts b/extensions/whatsapp/src/login-qr.ts index 3681d646252..352cf6e86b6 100644 --- a/extensions/whatsapp/src/login-qr.ts +++ b/extensions/whatsapp/src/login-qr.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; import { DisconnectReason } from "@whiskeysockets/baileys"; -import { loadConfig } from "../../../src/config/config.js"; -import { danger, info, success } from "../../../src/globals.js"; -import { logInfo } from "../../../src/logger.js"; -import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { danger, info, success } from "openclaw/plugin-sdk/runtime-env"; +import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { logInfo } from "openclaw/plugin-sdk/text-runtime"; import { resolveWhatsAppAccount } from "./accounts.js"; import { renderQrPngBase64 } from "./qr-image.js"; import { diff --git a/extensions/whatsapp/src/login.ts b/extensions/whatsapp/src/login.ts index 0923a38a122..43c16e1d298 100644 --- a/extensions/whatsapp/src/login.ts +++ b/extensions/whatsapp/src/login.ts @@ -1,9 +1,9 @@ import { DisconnectReason } from "@whiskeysockets/baileys"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import { loadConfig } from "../../../src/config/config.js"; -import { danger, info, success } from "../../../src/globals.js"; -import { logInfo } from "../../../src/logger.js"; -import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js"; +import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; +import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { danger, info, success } from "openclaw/plugin-sdk/runtime-env"; +import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { logInfo } from "openclaw/plugin-sdk/text-runtime"; import { resolveWhatsAppAccount } from "./accounts.js"; import { createWaSocket, diff --git a/extensions/whatsapp/src/media.ts b/extensions/whatsapp/src/media.ts index 2b297ef8907..33339451ec8 100644 --- a/extensions/whatsapp/src/media.ts +++ b/extensions/whatsapp/src/media.ts @@ -1,20 +1,20 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { logVerbose, shouldLogVerbose } from "../../../src/globals.js"; -import { SafeOpenError, readLocalFileSafely } from "../../../src/infra/fs-safe.js"; -import type { SsrFPolicy } from "../../../src/infra/net/ssrf.js"; -import { type MediaKind, maxBytesForKind } from "../../../src/media/constants.js"; -import { fetchRemoteMedia } from "../../../src/media/fetch.js"; +import { SafeOpenError, readLocalFileSafely } from "openclaw/plugin-sdk/infra-runtime"; +import type { SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime"; +import { type MediaKind, maxBytesForKind } from "openclaw/plugin-sdk/media-runtime"; +import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; import { convertHeicToJpeg, hasAlphaChannel, optimizeImageToPng, resizeToJpeg, -} from "../../../src/media/image-ops.js"; -import { getDefaultMediaLocalRoots } from "../../../src/media/local-roots.js"; -import { detectMime, extensionForMime, kindFromMime } from "../../../src/media/mime.js"; -import { resolveUserPath } from "../../../src/utils.js"; +} from "openclaw/plugin-sdk/media-runtime"; +import { getDefaultMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { detectMime, extensionForMime, kindFromMime } from "openclaw/plugin-sdk/media-runtime"; +import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { resolveUserPath } from "openclaw/plugin-sdk/text-runtime"; export type WebMediaResult = { buffer: Buffer; diff --git a/extensions/whatsapp/src/monitor-inbox.test-harness.ts b/extensions/whatsapp/src/monitor-inbox.test-harness.ts index 43bc731c459..3aefaf7a4f1 100644 --- a/extensions/whatsapp/src/monitor-inbox.test-harness.ts +++ b/extensions/whatsapp/src/monitor-inbox.test-harness.ts @@ -2,8 +2,8 @@ import { EventEmitter } from "node:events"; import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; +import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { afterEach, beforeEach, expect, vi } from "vitest"; -import { resetLogger, setLoggerOverride } from "../../../src/logging.js"; // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). // oxlint-disable-next-line typescript/no-explicit-any @@ -81,8 +81,8 @@ function getPairingStoreMocks() { const sock: MockSock = createMockSock(); -vi.mock("../../../src/media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, saveMediaBuffer: vi.fn().mockResolvedValue({ @@ -94,15 +94,15 @@ vi.mock("../../../src/media/store.js", async (importOriginal) => { }; }); -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => mockLoadConfig(), }; }); -vi.mock("../../../src/pairing/pairing-store.js", () => getPairingStoreMocks()); +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => getPairingStoreMocks()); vi.mock("./session.js", () => ({ createWaSocket: vi.fn().mockResolvedValue(sock), diff --git a/extensions/whatsapp/src/normalize.ts b/extensions/whatsapp/src/normalize.ts index 82ee5d8296d..bfecb31e4a5 100644 --- a/extensions/whatsapp/src/normalize.ts +++ b/extensions/whatsapp/src/normalize.ts @@ -2,4 +2,4 @@ export { looksLikeWhatsAppTargetId, normalizeWhatsAppAllowFromEntries, normalizeWhatsAppMessagingTarget, -} from "../../../src/channels/plugins/normalize/whatsapp.js"; +} from "openclaw/plugin-sdk/channel-runtime"; diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index ba84e336d0e..0cd0290e913 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -1,9 +1,9 @@ -import { chunkText } from "../../../src/auto-reply/chunk.js"; -import { sendTextMediaPayload } from "../../../src/channels/plugins/outbound/direct-text-media.js"; -import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; -import { shouldLogVerbose } from "../../../src/globals.js"; -import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; -import { resolveWhatsAppOutboundTarget } from "../../../src/whatsapp/resolve-outbound-target.js"; +import { sendTextMediaPayload } from "openclaw/plugin-sdk/channel-runtime"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { chunkText } from "openclaw/plugin-sdk/reply-runtime"; +import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { resolveWhatsAppOutboundTarget } from "openclaw/plugin-sdk/whatsapp"; import { sendMessageWhatsApp, sendPollWhatsApp } from "./send.js"; function trimLeadingWhitespace(text: string | undefined): string { diff --git a/extensions/whatsapp/src/plugin-shared.ts b/extensions/whatsapp/src/plugin-shared.ts index 1ab5d80220c..96a5f86e6f9 100644 --- a/extensions/whatsapp/src/plugin-shared.ts +++ b/extensions/whatsapp/src/plugin-shared.ts @@ -1,4 +1,4 @@ -import { type ChannelPlugin } from "../../../src/plugin-sdk-internal/whatsapp.js"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/whatsapp"; import { type ResolvedWhatsAppAccount } from "./accounts.js"; async function loadWhatsAppChannelRuntime() { diff --git a/extensions/whatsapp/src/qr-image.ts b/extensions/whatsapp/src/qr-image.ts index d4d8b9c7b2f..be6b10f5b0e 100644 --- a/extensions/whatsapp/src/qr-image.ts +++ b/extensions/whatsapp/src/qr-image.ts @@ -1,6 +1,6 @@ +import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime"; import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js"; import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js"; -import { encodePngRgba, fillPixel } from "../../../src/media/png-encode.js"; type QRCodeConstructor = new ( typeNumber: number, diff --git a/extensions/whatsapp/src/reconnect.ts b/extensions/whatsapp/src/reconnect.ts index d99ddf98ad6..e5e34888cef 100644 --- a/extensions/whatsapp/src/reconnect.ts +++ b/extensions/whatsapp/src/reconnect.ts @@ -1,8 +1,8 @@ import { randomUUID } from "node:crypto"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { BackoffPolicy } from "../../../src/infra/backoff.js"; -import { computeBackoff, sleepWithAbort } from "../../../src/infra/backoff.js"; -import { clamp } from "../../../src/utils.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime"; +import { computeBackoff, sleepWithAbort } from "openclaw/plugin-sdk/infra-runtime"; +import { clamp } from "openclaw/plugin-sdk/text-runtime"; export type ReconnectPolicy = BackoffPolicy & { maxAttempts: number; diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index e103cc878f0..8fc8b9e7ed9 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -1,7 +1,5 @@ -import { - createPluginRuntimeStore, - type PluginRuntime, -} from "../../../src/plugin-sdk-internal/core.js"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = createPluginRuntimeStore("WhatsApp runtime not initialized"); diff --git a/extensions/whatsapp/src/send.ts b/extensions/whatsapp/src/send.ts index 4ac9c03faf4..c59c5dd2008 100644 --- a/extensions/whatsapp/src/send.ts +++ b/extensions/whatsapp/src/send.ts @@ -1,13 +1,13 @@ -import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js"; -import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; -import { generateSecureUuid } from "../../../src/infra/secure-random.js"; -import { getChildLogger } from "../../../src/logging/logger.js"; -import { redactIdentifier } from "../../../src/logging/redact-identifier.js"; -import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; -import { convertMarkdownTables } from "../../../src/markdown/tables.js"; -import { markdownToWhatsApp } from "../../../src/markdown/whatsapp.js"; -import { normalizePollInput, type PollInput } from "../../../src/polls.js"; -import { toWhatsappJid } from "../../../src/utils.js"; +import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { generateSecureUuid } from "openclaw/plugin-sdk/infra-runtime"; +import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/media-runtime"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { getChildLogger } from "openclaw/plugin-sdk/text-runtime"; +import { redactIdentifier } from "openclaw/plugin-sdk/text-runtime"; +import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; +import { markdownToWhatsApp } from "openclaw/plugin-sdk/text-runtime"; +import { toWhatsappJid } from "openclaw/plugin-sdk/text-runtime"; import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "./accounts.js"; import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js"; import { loadWebMedia } from "./media.js"; diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index 8fc7f9fd1fc..80690b110eb 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -7,12 +7,12 @@ import { makeWASocket, useMultiFileAuthState, } from "@whiskeysockets/baileys"; +import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; +import { VERSION } from "openclaw/plugin-sdk/cli-runtime"; +import { danger, success } from "openclaw/plugin-sdk/runtime-env"; +import { getChildLogger, toPinoLikeLogger } from "openclaw/plugin-sdk/runtime-env"; +import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-runtime"; import qrcode from "qrcode-terminal"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import { danger, success } from "../../../src/globals.js"; -import { getChildLogger, toPinoLikeLogger } from "../../../src/logging.js"; -import { ensureDir, resolveUserPath } from "../../../src/utils.js"; -import { VERSION } from "../../../src/version.js"; import { maybeRestoreCredsFromBackup, readCredsJsonRaw, diff --git a/extensions/whatsapp/src/setup-core.ts b/extensions/whatsapp/src/setup-core.ts index 346c9aa0e8d..e7a11eedbf6 100644 --- a/extensions/whatsapp/src/setup-core.ts +++ b/extensions/whatsapp/src/setup-core.ts @@ -1,12 +1,52 @@ -import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/plugin-sdk-internal/setup.js"; +import { + applyAccountNameToChannelSection, + type ChannelSetupAdapter, + migrateBaseNameToDefaultAccount, + normalizeAccountId, +} from "openclaw/plugin-sdk/setup"; const channel = "whatsapp" as const; -export const whatsappSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ - channelKey: channel, - alwaysUseAccounts: true, - buildPatch: (input) => ({ - ...(input.authDir ? { authDir: input.authDir } : {}), - }), -}); +export const whatsappSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + alwaysUseAccounts: true, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + alwaysUseAccounts: true, + }); + const next = migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + alwaysUseAccounts: true, + }); + const entry = { + ...next.channels?.whatsapp?.accounts?.[accountId], + ...(input.authDir ? { authDir: input.authDir } : {}), + enabled: true, + }; + return { + ...next, + channels: { + ...next.channels, + whatsapp: { + ...next.channels?.whatsapp, + accounts: { + ...next.channels?.whatsapp?.accounts, + [accountId]: entry, + }, + }, + }, + }; + }, +}; diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 47e84de6860..bb87fc5b962 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -9,10 +9,10 @@ import { pathExists, splitSetupEntries, setSetupChannelEnabled, - type DmPolicy, type OpenClawConfig, -} from "../../../src/plugin-sdk-internal/setup.js"; -import type { ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; +} from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { type DmPolicy } from "openclaw/plugin-sdk/whatsapp"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; import { loginWeb } from "./login.js"; import { whatsappSetupAdapter } from "./setup-core.js"; diff --git a/extensions/whatsapp/src/status-issues.ts b/extensions/whatsapp/src/status-issues.ts index bddd6dd7d9d..f369ba29cda 100644 --- a/extensions/whatsapp/src/status-issues.ts +++ b/extensions/whatsapp/src/status-issues.ts @@ -2,12 +2,12 @@ import { asString, collectIssuesForEnabledAccounts, isRecord, -} from "../../../src/channels/plugins/status-issues/shared.js"; +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelAccountSnapshot, ChannelStatusIssue, -} from "../../../src/channels/plugins/types.js"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; +} from "openclaw/plugin-sdk/channel-runtime"; +import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; type WhatsAppAccountStatus = { accountId?: unknown; diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index b3289164463..bb2cd3d6fa0 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -30,8 +30,8 @@ export function resetLoadConfigMock() { (globalThis as Record)[CONFIG_KEY] = () => DEFAULT_CONFIG; } -vi.mock("../../../src/config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => { @@ -51,7 +51,7 @@ vi.mock("../../config/config.js", async (importOriginal) => { // `../../config/config.js` is correct for modules under `src/web/auto-reply/*`. // For typing in this file (which lives in `src/web/*`), refer to the same module // via the local relative path. - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, loadConfig: () => { @@ -64,8 +64,8 @@ vi.mock("../../config/config.js", async (importOriginal) => { }; }); -vi.mock("../../../src/media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { + const actual = await importOriginal(); const mockModule = Object.create(null) as Record; Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); Object.defineProperty(mockModule, "saveMediaBuffer", { diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index b5f6830fd2e..7771575795a 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -1,12 +1,11 @@ -import { normalizeProviderId } from "../../src/agents/provider-id.js"; +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { normalizeProviderId } from "openclaw/plugin-sdk/provider-models"; import { createPluginBackedWebSearchProvider, getScopedCredentialValue, setScopedCredentialValue, -} from "../../src/agents/tools/web-search-plugin-factory.js"; -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"; +} from "openclaw/plugin-sdk/provider-web-search"; import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "xai"; diff --git a/extensions/xai/onboard.ts b/extensions/xai/onboard.ts index ee5cfbc92cf..6abc7477e6c 100644 --- a/extensions/xai/onboard.ts +++ b/extensions/xai/onboard.ts @@ -1,16 +1,15 @@ -import { - applyAgentDefaultModelPrimary, - applyProviderConfigWithDefaultModel, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; import { buildXaiModelDefinition, XAI_BASE_URL, XAI_DEFAULT_MODEL_ID, - XAI_DEFAULT_MODEL_REF, -} from "./model-definitions.js"; +} from "openclaw/plugin-sdk/provider-models"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModel, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; -export { XAI_DEFAULT_MODEL_REF }; +export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`; export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index 33eb6e47bf9..1badf6e2d9d 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { PROVIDER_LABELS } from "../../src/infra/provider-usage.shared.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { PROVIDER_LABELS } from "openclaw/plugin-sdk/provider-usage"; import { applyXiaomiConfig, XIAOMI_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildXiaomiProvider } from "./provider-catalog.js"; diff --git a/extensions/xiaomi/onboard.ts b/extensions/xiaomi/onboard.ts index 3f3eef149c4..80d0ad1cd16 100644 --- a/extensions/xiaomi/onboard.ts +++ b/extensions/xiaomi/onboard.ts @@ -1,8 +1,8 @@ import { applyAgentDefaultModelPrimary, applyProviderConfigWithDefaultModels, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; import { buildXiaomiProvider, XIAOMI_DEFAULT_MODEL_ID } from "./provider-catalog.js"; export const XIAOMI_DEFAULT_MODEL_REF = `xiaomi/${XIAOMI_DEFAULT_MODEL_ID}`; diff --git a/extensions/xiaomi/provider-catalog.ts b/extensions/xiaomi/provider-catalog.ts index b62de84cf68..91329eeb87d 100644 --- a/extensions/xiaomi/provider-catalog.ts +++ b/extensions/xiaomi/provider-catalog.ts @@ -1,4 +1,4 @@ -import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-models"; const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic"; export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash"; diff --git a/extensions/zai/detect.ts b/extensions/zai/detect.ts index 07f06a9f052..9bd1f25f50a 100644 --- a/extensions/zai/detect.ts +++ b/extensions/zai/detect.ts @@ -2,7 +2,7 @@ import { detectZaiEndpoint as detectZaiEndpointCore, type ZaiDetectedEndpoint, type ZaiEndpointId, -} from "../../src/commands/zai-endpoint-detect.js"; +} from "openclaw/plugin-sdk/zai"; type DetectZaiEndpointFn = typeof detectZaiEndpointCore; diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index 21ddc902902..0faef49c4fb 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -10,23 +10,21 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { upsertAuthProfile } from "../../src/agents/auth-profiles.js"; -import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; -import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { createZaiToolStreamWrapper } from "../../src/agents/pi-embedded-runner/zai-stream-wrappers.js"; +import { resolveRequiredHomeDir } from "openclaw/plugin-sdk/infra-runtime"; import { + applyAuthProfileConfig, + buildApiKeyCredential, + ensureApiKeyFromOptionEnvOrPrompt, normalizeApiKeyInput, + normalizeOptionalSecretInput, + type SecretInput, + upsertAuthProfile, validateApiKeyInput, -} from "../../src/commands/auth-choice.api-key.js"; -import { ensureApiKeyFromOptionEnvOrPrompt } from "../../src/commands/auth-choice.apply-helpers.js"; -import { buildApiKeyCredential } from "../../src/commands/auth-credentials.js"; -import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; -import type { SecretInput } from "../../src/config/types.secrets.js"; -import { resolveRequiredHomeDir } from "../../src/infra/home-dir.js"; -import { fetchZaiUsage } from "../../src/infra/provider-usage.fetch.js"; -import { normalizeOptionalSecretInput } from "../../src/utils/normalize-secret-input.js"; +} from "openclaw/plugin-sdk/provider-auth"; +import { DEFAULT_CONTEXT_TOKENS, normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; +import { createZaiToolStreamWrapper } from "openclaw/plugin-sdk/provider-stream"; +import { fetchZaiUsage } from "openclaw/plugin-sdk/provider-usage"; import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js"; -import { zaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyZaiConfig, applyZaiProviderConfig, ZAI_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "zai"; @@ -335,7 +333,6 @@ const zaiPlugin = { fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), isCacheTtlEligible: () => true, }); - api.registerMediaUnderstandingProvider(zaiMediaUnderstandingProvider); }, }; diff --git a/extensions/zai/onboard.ts b/extensions/zai/onboard.ts index a440387cf7b..f293e0f7632 100644 --- a/extensions/zai/onboard.ts +++ b/extensions/zai/onboard.ts @@ -1,16 +1,15 @@ -import { - applyAgentDefaultModelPrimary, - applyProviderConfigWithModelCatalog, -} from "../../src/commands/onboard-auth.config-shared.js"; -import type { OpenClawConfig } from "../../src/config/config.js"; import { buildZaiModelDefinition, resolveZaiBaseUrl, ZAI_DEFAULT_MODEL_ID, - ZAI_DEFAULT_MODEL_REF, -} from "./model-definitions.js"; +} from "openclaw/plugin-sdk/provider-models"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; -export { ZAI_DEFAULT_MODEL_REF }; +export const ZAI_DEFAULT_MODEL_REF = `zai/${ZAI_DEFAULT_MODEL_ID}`; const ZAI_DEFAULT_MODELS = [ buildZaiModelDefinition({ id: "glm-5" }), diff --git a/package.json b/package.json index 95763eb8a0f..456603ea22c 100644 --- a/package.json +++ b/package.json @@ -70,10 +70,82 @@ "types": "./dist/plugin-sdk/routing.d.ts", "default": "./dist/plugin-sdk/routing.js" }, + "./plugin-sdk/runtime": { + "types": "./dist/plugin-sdk/runtime.d.ts", + "default": "./dist/plugin-sdk/runtime.js" + }, + "./plugin-sdk/runtime-env": { + "types": "./dist/plugin-sdk/runtime-env.d.ts", + "default": "./dist/plugin-sdk/runtime-env.js" + }, "./plugin-sdk/setup": { "types": "./dist/plugin-sdk/setup.d.ts", "default": "./dist/plugin-sdk/setup.js" }, + "./plugin-sdk/config-runtime": { + "types": "./dist/plugin-sdk/config-runtime.d.ts", + "default": "./dist/plugin-sdk/config-runtime.js" + }, + "./plugin-sdk/reply-runtime": { + "types": "./dist/plugin-sdk/reply-runtime.d.ts", + "default": "./dist/plugin-sdk/reply-runtime.js" + }, + "./plugin-sdk/channel-runtime": { + "types": "./dist/plugin-sdk/channel-runtime.d.ts", + "default": "./dist/plugin-sdk/channel-runtime.js" + }, + "./plugin-sdk/infra-runtime": { + "types": "./dist/plugin-sdk/infra-runtime.d.ts", + "default": "./dist/plugin-sdk/infra-runtime.js" + }, + "./plugin-sdk/media-runtime": { + "types": "./dist/plugin-sdk/media-runtime.d.ts", + "default": "./dist/plugin-sdk/media-runtime.js" + }, + "./plugin-sdk/conversation-runtime": { + "types": "./dist/plugin-sdk/conversation-runtime.d.ts", + "default": "./dist/plugin-sdk/conversation-runtime.js" + }, + "./plugin-sdk/text-runtime": { + "types": "./dist/plugin-sdk/text-runtime.d.ts", + "default": "./dist/plugin-sdk/text-runtime.js" + }, + "./plugin-sdk/agent-runtime": { + "types": "./dist/plugin-sdk/agent-runtime.d.ts", + "default": "./dist/plugin-sdk/agent-runtime.js" + }, + "./plugin-sdk/plugin-runtime": { + "types": "./dist/plugin-sdk/plugin-runtime.d.ts", + "default": "./dist/plugin-sdk/plugin-runtime.js" + }, + "./plugin-sdk/security-runtime": { + "types": "./dist/plugin-sdk/security-runtime.d.ts", + "default": "./dist/plugin-sdk/security-runtime.js" + }, + "./plugin-sdk/gateway-runtime": { + "types": "./dist/plugin-sdk/gateway-runtime.d.ts", + "default": "./dist/plugin-sdk/gateway-runtime.js" + }, + "./plugin-sdk/cli-runtime": { + "types": "./dist/plugin-sdk/cli-runtime.d.ts", + "default": "./dist/plugin-sdk/cli-runtime.js" + }, + "./plugin-sdk/hook-runtime": { + "types": "./dist/plugin-sdk/hook-runtime.d.ts", + "default": "./dist/plugin-sdk/hook-runtime.js" + }, + "./plugin-sdk/process-runtime": { + "types": "./dist/plugin-sdk/process-runtime.d.ts", + "default": "./dist/plugin-sdk/process-runtime.js" + }, + "./plugin-sdk/acp-runtime": { + "types": "./dist/plugin-sdk/acp-runtime.d.ts", + "default": "./dist/plugin-sdk/acp-runtime.js" + }, + "./plugin-sdk/zai": { + "types": "./dist/plugin-sdk/zai.d.ts", + "default": "./dist/plugin-sdk/zai.js" + }, "./plugin-sdk/telegram": { "types": "./dist/plugin-sdk/telegram.d.ts", "default": "./dist/plugin-sdk/telegram.js" @@ -230,10 +302,18 @@ "types": "./dist/plugin-sdk/account-id.d.ts", "default": "./dist/plugin-sdk/account-id.js" }, + "./plugin-sdk/account-resolution": { + "types": "./dist/plugin-sdk/account-resolution.d.ts", + "default": "./dist/plugin-sdk/account-resolution.js" + }, "./plugin-sdk/allow-from": { "types": "./dist/plugin-sdk/allow-from.d.ts", "default": "./dist/plugin-sdk/allow-from.js" }, + "./plugin-sdk/allowlist-config-edit": { + "types": "./dist/plugin-sdk/allowlist-config-edit.d.ts", + "default": "./dist/plugin-sdk/allowlist-config-edit.js" + }, "./plugin-sdk/boolean-param": { "types": "./dist/plugin-sdk/boolean-param.d.ts", "default": "./dist/plugin-sdk/boolean-param.js" @@ -254,10 +334,50 @@ "types": "./dist/plugin-sdk/keyed-async-queue.d.ts", "default": "./dist/plugin-sdk/keyed-async-queue.js" }, + "./plugin-sdk/provider-auth": { + "types": "./dist/plugin-sdk/provider-auth.d.ts", + "default": "./dist/plugin-sdk/provider-auth.js" + }, + "./plugin-sdk/provider-models": { + "types": "./dist/plugin-sdk/provider-models.d.ts", + "default": "./dist/plugin-sdk/provider-models.js" + }, + "./plugin-sdk/provider-onboard": { + "types": "./dist/plugin-sdk/provider-onboard.d.ts", + "default": "./dist/plugin-sdk/provider-onboard.js" + }, + "./plugin-sdk/provider-stream": { + "types": "./dist/plugin-sdk/provider-stream.d.ts", + "default": "./dist/plugin-sdk/provider-stream.js" + }, + "./plugin-sdk/provider-usage": { + "types": "./dist/plugin-sdk/provider-usage.d.ts", + "default": "./dist/plugin-sdk/provider-usage.js" + }, + "./plugin-sdk/provider-web-search": { + "types": "./dist/plugin-sdk/provider-web-search.d.ts", + "default": "./dist/plugin-sdk/provider-web-search.js" + }, "./plugin-sdk/request-url": { "types": "./dist/plugin-sdk/request-url.d.ts", "default": "./dist/plugin-sdk/request-url.js" }, + "./plugin-sdk/runtime-store": { + "types": "./dist/plugin-sdk/runtime-store.d.ts", + "default": "./dist/plugin-sdk/runtime-store.js" + }, + "./plugin-sdk/speech": { + "types": "./dist/plugin-sdk/speech.d.ts", + "default": "./dist/plugin-sdk/speech.js" + }, + "./plugin-sdk/state-paths": { + "types": "./dist/plugin-sdk/state-paths.d.ts", + "default": "./dist/plugin-sdk/state-paths.js" + }, + "./plugin-sdk/tool-send": { + "types": "./dist/plugin-sdk/tool-send.d.ts", + "default": "./dist/plugin-sdk/tool-send.js" + }, "./cli-entry": "./openclaw.mjs" }, "scripts": { diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index f99be019a69..e2de1d74f1f 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -7,7 +7,25 @@ "sandbox", "self-hosted-provider-setup", "routing", + "runtime", + "runtime-env", "setup", + "config-runtime", + "reply-runtime", + "channel-runtime", + "infra-runtime", + "media-runtime", + "conversation-runtime", + "text-runtime", + "agent-runtime", + "plugin-runtime", + "security-runtime", + "gateway-runtime", + "cli-runtime", + "hook-runtime", + "process-runtime", + "acp-runtime", + "zai", "telegram", "discord", "slack", @@ -47,11 +65,23 @@ "zalo", "zalouser", "account-id", + "account-resolution", "allow-from", + "allowlist-config-edit", "boolean-param", "channel-config-helpers", "group-access", "json-store", "keyed-async-queue", - "request-url" + "provider-auth", + "provider-models", + "provider-onboard", + "provider-stream", + "provider-usage", + "provider-web-search", + "request-url", + "runtime-store", + "speech", + "state-paths", + "tool-send" ] diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index ba001a6746a..4daef42a21f 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -19,11 +19,11 @@ import { createInternalHookEvent, triggerInternalHook } from "../../hooks/intern import { getMachineDisplayName } from "../../infra/machine-name.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getMemorySearchManager } from "../../memory/index.js"; -import { resolveSignalReactionLevel } from "../../plugin-sdk-internal/signal.js"; +import { resolveSignalReactionLevel } from "../../plugin-sdk/signal.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramReactionLevel, -} from "../../plugin-sdk-internal/telegram.js"; +} from "../../plugin-sdk/telegram.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index e8efa015137..0ea66825ff1 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -16,11 +16,11 @@ import { ensureGlobalUndiciStreamTimeouts, } from "../../../infra/net/undici-global-dispatcher.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; -import { resolveSignalReactionLevel } from "../../../plugin-sdk-internal/signal.js"; +import { resolveSignalReactionLevel } from "../../../plugin-sdk/signal.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramReactionLevel, -} from "../../../plugin-sdk-internal/telegram.js"; +} from "../../../plugin-sdk/telegram.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import type { PluginHookAgentContext, diff --git a/src/agents/tools/discord-actions-guild.ts b/src/agents/tools/discord-actions-guild.ts index 54386ad4267..fa427d87650 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/src/agents/tools/discord-actions-guild.ts @@ -19,8 +19,8 @@ import { setChannelPermissionDiscord, uploadEmojiDiscord, uploadStickerDiscord, -} from "../../plugin-sdk-internal/discord.js"; -import { getPresence } from "../../plugin-sdk-internal/discord.js"; +} from "../../plugin-sdk/discord.js"; +import { getPresence } from "../../plugin-sdk/discord.js"; import { type ActionGate, jsonResult, diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 8a7f93aacbb..20fdfcc6a02 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -1,6 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { DiscordActionConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; import { createThreadDiscord, deleteMessageDiscord, @@ -22,16 +23,9 @@ import { sendStickerDiscord, sendVoiceMessageDiscord, unpinMessageDiscord, -} from "../../plugin-sdk-internal/discord.js"; -import type { - DiscordSendComponents, - DiscordSendEmbeds, -} from "../../plugin-sdk-internal/discord.js"; -import { - readDiscordComponentSpec, - resolveDiscordChannelId, -} from "../../plugin-sdk-internal/discord.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +} from "../../plugin-sdk/discord.js"; +import type { DiscordSendComponents, DiscordSendEmbeds } from "../../plugin-sdk/discord.js"; +import { readDiscordComponentSpec, resolveDiscordChannelId } from "../../plugin-sdk/discord.js"; import { resolvePollMaxSelections } from "../../polls.js"; import { withNormalizedTimestamp } from "../date-time.js"; import { assertMediaNotDataUrl } from "../sandbox-paths.js"; diff --git a/src/agents/tools/discord-actions-moderation.ts b/src/agents/tools/discord-actions-moderation.ts index 63c3cc601bc..56d7a80d4c9 100644 --- a/src/agents/tools/discord-actions-moderation.ts +++ b/src/agents/tools/discord-actions-moderation.ts @@ -5,7 +5,7 @@ import { hasAnyGuildPermissionDiscord, kickMemberDiscord, timeoutMemberDiscord, -} from "../../plugin-sdk-internal/discord.js"; +} from "../../plugin-sdk/discord.js"; import { type ActionGate, jsonResult, readStringParam } from "./common.js"; import { isDiscordModerationAction, diff --git a/src/agents/tools/discord-actions-presence.ts b/src/agents/tools/discord-actions-presence.ts index fdfa53e2323..53c42829bb0 100644 --- a/src/agents/tools/discord-actions-presence.ts +++ b/src/agents/tools/discord-actions-presence.ts @@ -1,7 +1,7 @@ import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { DiscordActionConfig } from "../../config/config.js"; -import { getGateway } from "../../plugin-sdk-internal/discord.js"; +import { getGateway } from "../../plugin-sdk/discord.js"; import { type ActionGate, jsonResult, readStringParam } from "./common.js"; const ACTIVITY_TYPE_MAP: Record = { diff --git a/src/agents/tools/discord-actions.ts b/src/agents/tools/discord-actions.ts index 0e380b8d383..b953e56cffd 100644 --- a/src/agents/tools/discord-actions.ts +++ b/src/agents/tools/discord-actions.ts @@ -1,6 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; -import { createDiscordActionGate } from "../../plugin-sdk-internal/discord.js"; +import { createDiscordActionGate } from "../../plugin-sdk/discord.js"; import { readStringParam } from "./common.js"; import { handleDiscordGuildAction } from "./discord-actions-guild.js"; import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index c7fc16ed8b1..e9089cbfdcc 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -15,14 +15,14 @@ import { removeSlackReaction, sendSlackMessage, unpinSlackMessage, -} from "../../plugin-sdk-internal/slack.js"; +} from "../../plugin-sdk/slack.js"; import { parseSlackBlocksInput, parseSlackTarget, recordSlackThreadParticipation, resolveSlackAccount, resolveSlackChannelId, -} from "../../plugin-sdk-internal/slack.js"; +} from "../../plugin-sdk/slack.js"; import { withNormalizedTimestamp } from "../date-time.js"; import { createActionGate, diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 9f2d48831c3..d648b1e5f41 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -1,17 +1,15 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; +import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; import { createTelegramActionGate, resolveTelegramPollActionGateState, -} from "../../plugin-sdk-internal/telegram.js"; -import type { - TelegramButtonStyle, - TelegramInlineButtons, -} from "../../plugin-sdk-internal/telegram.js"; +} from "../../plugin-sdk/telegram.js"; +import type { TelegramButtonStyle, TelegramInlineButtons } from "../../plugin-sdk/telegram.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, -} from "../../plugin-sdk-internal/telegram.js"; +} from "../../plugin-sdk/telegram.js"; import { createForumTopicTelegram, deleteMessageTelegram, @@ -21,14 +19,13 @@ import { sendMessageTelegram, sendPollTelegram, sendStickerTelegram, -} from "../../plugin-sdk-internal/telegram.js"; +} from "../../plugin-sdk/telegram.js"; import { getCacheStats, resolveTelegramReactionLevel, resolveTelegramToken, searchStickers, -} from "../../plugin-sdk-internal/telegram.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +} from "../../plugin-sdk/telegram.js"; import { resolvePollMaxSelections } from "../../polls.js"; import { jsonResult, diff --git a/src/agents/tools/whatsapp-actions.ts b/src/agents/tools/whatsapp-actions.ts index 30f36331d18..a84dc0a3d5b 100644 --- a/src/agents/tools/whatsapp-actions.ts +++ b/src/agents/tools/whatsapp-actions.ts @@ -1,6 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; -import { sendReactionWhatsApp } from "../../plugin-sdk-internal/whatsapp.js"; +import { sendReactionWhatsApp } from "../../plugin-sdk/whatsapp.js"; import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js"; import { resolveAuthorizedWhatsAppOutboundTarget } from "./whatsapp-target-auth.js"; diff --git a/src/agents/tools/whatsapp-target-auth.ts b/src/agents/tools/whatsapp-target-auth.ts index 76e7e15d084..edc0052fbab 100644 --- a/src/agents/tools/whatsapp-target-auth.ts +++ b/src/agents/tools/whatsapp-target-auth.ts @@ -1,5 +1,5 @@ import type { OpenClawConfig } from "../../config/config.js"; -import { resolveWhatsAppAccount } from "../../plugin-sdk-internal/whatsapp.js"; +import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; import { ToolAuthorizationError } from "./common.js"; diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 5f259c1b45a..630ea988c05 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -3,7 +3,7 @@ import { logVerbose } from "../../globals.js"; import { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, -} from "../../plugin-sdk-internal/telegram.js"; +} from "../../plugin-sdk/telegram.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 99e02cfa81e..25f309361d2 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -16,7 +16,7 @@ import { calculateTotalPages, getModelsPageSize, type ProviderInfo, -} from "../../plugin-sdk-internal/telegram.js"; +} from "../../plugin-sdk/telegram.js"; import type { ReplyPayload } from "../types.js"; import { rejectUnauthorizedCommand } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index d27bdb25d61..521d3bd6fea 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -8,7 +8,7 @@ import { } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { buildBrowseProvidersButton } from "../../plugin-sdk-internal/telegram.js"; +import { buildBrowseProvidersButton } from "../../plugin-sdk/telegram.js"; import { shortenHomePath } from "../../utils.js"; import { resolveSelectedAndActiveModel } from "../model-runtime.js"; import type { ReplyPayload } from "../types.js"; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index b426b18eab5..a32fdc3ba87 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -3,7 +3,7 @@ import type { MediaUnderstandingDecision, MediaUnderstandingOutput, } from "../media-understanding/types.js"; -import type { StickerMetadata } from "../plugin-sdk-internal/telegram.js"; +import type { StickerMetadata } from "../plugin-sdk/telegram.js"; import type { InputProvenance } from "../sessions/input-provenance.js"; import type { InternalMessageChannel } from "../utils/message-channel.js"; import type { CommandArgs } from "./commands-registry.types.js"; diff --git a/src/channel-web.ts b/src/channel-web.ts index f7e451b142a..e6df4bda0d7 100644 --- a/src/channel-web.ts +++ b/src/channel-web.ts @@ -7,15 +7,11 @@ export { monitorWebChannel, resolveHeartbeatRecipients, runWebHeartbeatOnce, -} from "./plugin-sdk-internal/whatsapp.js"; -export { - extractMediaPlaceholder, - extractText, - monitorWebInbox, -} from "./plugin-sdk-internal/whatsapp.js"; -export { loginWeb } from "./plugin-sdk-internal/whatsapp.js"; -export { loadWebMedia, optimizeImageToJpeg } from "./plugin-sdk-internal/whatsapp.js"; -export { sendMessageWhatsApp } from "./plugin-sdk-internal/whatsapp.js"; +} from "./plugin-sdk/whatsapp.js"; +export { extractMediaPlaceholder, extractText, monitorWebInbox } from "./plugin-sdk/whatsapp.js"; +export { loginWeb } from "./plugin-sdk/whatsapp.js"; +export { loadWebMedia, optimizeImageToJpeg } from "./plugin-sdk/whatsapp.js"; +export { sendMessageWhatsApp } from "./plugin-sdk/whatsapp.js"; export { createWaSocket, formatError, @@ -26,4 +22,4 @@ export { WA_WEB_AUTH_DIR, waitForWaConnection, webAuthExists, -} from "./plugin-sdk-internal/whatsapp.js"; +} from "./plugin-sdk/whatsapp.js"; diff --git a/src/channels/plugins/actions/discord.ts b/src/channels/plugins/actions/discord.ts index ec11ca6c970..4615a88f3c5 100644 --- a/src/channels/plugins/actions/discord.ts +++ b/src/channels/plugins/actions/discord.ts @@ -1,2 +1,2 @@ // Public entrypoint for the Discord channel action adapter. -export * from "../../../plugin-sdk-internal/discord.js"; +export * from "../../../plugin-sdk/discord.js"; diff --git a/src/channels/plugins/actions/signal.ts b/src/channels/plugins/actions/signal.ts index 7db723f305e..60a70bac4c0 100644 --- a/src/channels/plugins/actions/signal.ts +++ b/src/channels/plugins/actions/signal.ts @@ -5,7 +5,7 @@ import { resolveSignalAccount, resolveSignalReactionLevel, sendReactionSignal, -} from "../../../plugin-sdk-internal/signal.js"; +} from "../../../plugin-sdk/signal.js"; import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js"; import { resolveReactionMessageId } from "./reaction-message-id.js"; diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index e34c4598ade..e811e757b94 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -1,2 +1,2 @@ // Public entrypoint for the Telegram channel action adapter. -export * from "../../../plugin-sdk-internal/telegram.js"; +export * from "../../../plugin-sdk/telegram.js"; diff --git a/src/channels/plugins/agent-tools/whatsapp-login.ts b/src/channels/plugins/agent-tools/whatsapp-login.ts index 661b49e083b..2204225bdda 100644 --- a/src/channels/plugins/agent-tools/whatsapp-login.ts +++ b/src/channels/plugins/agent-tools/whatsapp-login.ts @@ -1,2 +1,2 @@ // Shim: keep legacy import path while the runtime loads the plugin SDK surface. -export * from "../../../plugin-sdk-internal/whatsapp.js"; +export * from "../../../plugin-sdk/whatsapp.js"; diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index 94079daed04..f825fc73fe5 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -10,7 +10,7 @@ import type { GroupToolPolicyConfig, } from "../../config/types.tools.js"; import { resolveExactLineGroupConfigKey } from "../../line/group-keys.js"; -import { inspectSlackAccount } from "../../plugin-sdk-internal/slack.js"; +import { inspectSlackAccount } from "../../plugin-sdk/slack.js"; import { normalizeAtHashSlug, normalizeHyphenSlug } from "../../shared/string-normalization.js"; import type { ChannelGroupContext } from "./types.js"; diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index df53d1ff0e0..d559ca99b6a 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -4,22 +4,11 @@ import { isSlackInteractiveRepliesEnabled, listSlackMessageActions, resolveSlackChannelId, -} from "../../plugin-sdk-internal/slack.js"; -import { handleSlackMessageAction } from "../../plugin-sdk/slack-message-actions.js"; -import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js"; + handleSlackMessageAction, +} from "../../plugin-sdk/slack.js"; +import type { ChannelMessageActionAdapter } from "./types.js"; -type SlackActionAdapterOptions = { - includeReadThreadId?: boolean; - invoke?: ( - ctx: ChannelMessageActionContext, - ) => Parameters[0]["invoke"]; - skipNormalizeChannelId?: boolean; -}; - -export function createSlackActions( - providerId: string, - options?: SlackActionAdapterOptions, -): ChannelMessageActionAdapter { +export function createSlackActions(providerId: string): ChannelMessageActionAdapter { return { listActions: ({ cfg }) => listSlackMessageActions(cfg), getCapabilities: ({ cfg }) => { @@ -34,19 +23,16 @@ export function createSlackActions( }, extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => { - const invoke = - options?.invoke?.(ctx) ?? - (async (action, cfg, toolContext) => - await handleSlackAction(action, cfg, { - ...(toolContext as SlackActionContext | undefined), - mediaLocalRoots: ctx.mediaLocalRoots, - })); return await handleSlackMessageAction({ providerId, ctx, - normalizeChannelId: options?.skipNormalizeChannelId ? undefined : resolveSlackChannelId, - includeReadThreadId: options?.includeReadThreadId ?? true, - invoke, + normalizeChannelId: resolveSlackChannelId, + includeReadThreadId: true, + invoke: async (action, cfg, toolContext) => + await handleSlackAction(action, cfg, { + ...(toolContext as SlackActionContext | undefined), + mediaLocalRoots: ctx.mediaLocalRoots, + }), }); }, }; diff --git a/src/channels/read-only-account-inspect.discord.runtime.ts b/src/channels/read-only-account-inspect.discord.runtime.ts index 00d0943b1ec..9d2ac6ef427 100644 --- a/src/channels/read-only-account-inspect.discord.runtime.ts +++ b/src/channels/read-only-account-inspect.discord.runtime.ts @@ -1,2 +1,2 @@ -export { inspectDiscordAccount } from "../plugin-sdk-internal/discord.js"; -export type { InspectedDiscordAccount } from "../plugin-sdk-internal/discord.js"; +export { inspectDiscordAccount } from "../plugin-sdk/discord.js"; +export type { InspectedDiscordAccount } from "../plugin-sdk/discord.js"; diff --git a/src/channels/read-only-account-inspect.slack.runtime.ts b/src/channels/read-only-account-inspect.slack.runtime.ts index c3e2bd5d83c..a7526e2ea95 100644 --- a/src/channels/read-only-account-inspect.slack.runtime.ts +++ b/src/channels/read-only-account-inspect.slack.runtime.ts @@ -1,2 +1,2 @@ -export { inspectSlackAccount } from "../plugin-sdk-internal/slack.js"; -export type { InspectedSlackAccount } from "../plugin-sdk-internal/slack.js"; +export { inspectSlackAccount } from "../plugin-sdk/slack.js"; +export type { InspectedSlackAccount } from "../plugin-sdk/slack.js"; diff --git a/src/channels/read-only-account-inspect.telegram.runtime.ts b/src/channels/read-only-account-inspect.telegram.runtime.ts index 1e633a0ff8e..0ab48f2c241 100644 --- a/src/channels/read-only-account-inspect.telegram.runtime.ts +++ b/src/channels/read-only-account-inspect.telegram.runtime.ts @@ -1,2 +1,2 @@ -export { inspectTelegramAccount } from "../plugin-sdk-internal/telegram.js"; -export type { InspectedTelegramAccount } from "../plugin-sdk-internal/telegram.js"; +export { inspectTelegramAccount } from "../plugin-sdk/telegram.js"; +export type { InspectedTelegramAccount } from "../plugin-sdk/telegram.js"; diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 84bb107f97e..7ebfbf74f5b 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -35,32 +35,32 @@ export function createDefaultDeps(): CliDeps { return { whatsapp: createLazySender( "whatsapp", - () => import("../plugin-sdk-internal/whatsapp.js") as Promise>, + () => import("../plugin-sdk/whatsapp.js") as Promise>, "sendMessageWhatsApp", ), telegram: createLazySender( "telegram", - () => import("../plugin-sdk-internal/telegram.js") as Promise>, + () => import("../plugin-sdk/telegram.js") as Promise>, "sendMessageTelegram", ), discord: createLazySender( "discord", - () => import("../plugin-sdk-internal/discord.js") as Promise>, + () => import("../plugin-sdk/discord.js") as Promise>, "sendMessageDiscord", ), slack: createLazySender( "slack", - () => import("../plugin-sdk-internal/slack.js") as Promise>, + () => import("../plugin-sdk/slack.js") as Promise>, "sendMessageSlack", ), signal: createLazySender( "signal", - () => import("../plugin-sdk-internal/signal.js") as Promise>, + () => import("../plugin-sdk/signal.js") as Promise>, "sendMessageSignal", ), imessage: createLazySender( "imessage", - () => import("../plugin-sdk-internal/imessage.js") as Promise>, + () => import("../plugin-sdk/imessage.js") as Promise>, "sendMessageIMessage", ), }; @@ -70,4 +70,4 @@ export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { return createOutboundSendDepsFromCliSource(deps); } -export { logWebSelfId } from "../plugin-sdk-internal/whatsapp.js"; +export { logWebSelfId } from "../plugin-sdk/whatsapp.js"; diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 0c52c9b582a..a1cbf5fa6d9 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -29,7 +29,7 @@ import { listTelegramAccountIds, normalizeTelegramAllowFromEntry, resolveTelegramAccount, -} from "../plugin-sdk-internal/telegram.js"; +} from "../plugin-sdk/telegram.js"; import { formatChannelAccountsDefaultPath, formatSetExplicitDefaultInstruction, diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index c1297e7de4c..1deaad96d6f 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -9,7 +9,7 @@ import { listChatChannels, normalizeChatChannelId, } from "../channels/registry.js"; -import { hasAnyWhatsAppAuth } from "../plugin-sdk-internal/whatsapp.js"; +import { hasAnyWhatsAppAuth } from "../plugin-sdk/whatsapp.js"; import { loadPluginManifestRegistry, type PluginManifestRegistry, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 1b048bc9aa1..02103650589 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1,7 +1,7 @@ import { DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, -} from "../plugin-sdk-internal/discord.js"; +} from "../plugin-sdk/discord.js"; import { MEDIA_AUDIO_FIELD_HELP } from "./media-audio-field-metadata.js"; import { IRC_FIELD_HELP } from "./schema.irc.js"; import { describeTalkSilenceTimeoutDefaults } from "./talk-defaults.js"; diff --git a/src/config/sessions/explicit-session-key-normalization.ts b/src/config/sessions/explicit-session-key-normalization.ts index 16b43a7c43c..08543e5a6d0 100644 --- a/src/config/sessions/explicit-session-key-normalization.ts +++ b/src/config/sessions/explicit-session-key-normalization.ts @@ -1,5 +1,5 @@ import type { MsgContext } from "../../auto-reply/templating.js"; -import { normalizeExplicitDiscordSessionKey } from "../../plugin-sdk-internal/discord.js"; +import { normalizeExplicitDiscordSessionKey } from "../../plugin-sdk/discord.js"; type ExplicitSessionKeyNormalizer = (sessionKey: string, ctx: MsgContext) => string; type ExplicitSessionKeyNormalizerEntry = { diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index aea4e7f8cfd..c9269c6b8fd 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -1,4 +1,4 @@ -import type { DiscordPluralKitConfig } from "../plugin-sdk-internal/discord.js"; +import type { DiscordPluralKitConfig } from "../plugin-sdk/discord.js"; import type { BlockStreamingChunkConfig, BlockStreamingCoalesceConfig, diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index 585e273e613..e903cd15cab 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -13,7 +13,7 @@ import { resolveSessionDeliveryTarget, } from "../../infra/outbound/targets.js"; import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; -import { resolveWhatsAppAccount } from "../../plugin-sdk-internal/whatsapp.js"; +import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js"; import { buildChannelAccountBindings } from "../../routing/bindings.js"; import { normalizeAccountId, normalizeAgentId } from "../../routing/session-key.js"; import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index fe35da1f356..0ad655f4990 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -13,7 +13,7 @@ import { CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; import { loadConfig } from "../config/config.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; -import { handleSlackHttpRequest } from "../plugin-sdk-internal/slack.js"; +import { handleSlackHttpRequest } from "../plugin-sdk/slack.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 6646ab02e75..b429365a4a4 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -15,7 +15,7 @@ import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js import type { SessionScope } from "../config/sessions/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveChannelAllowFromPath } from "../pairing/pairing-store.js"; -import { listTelegramAccountIds } from "../plugin-sdk-internal/telegram.js"; +import { listTelegramAccountIds } from "../plugin-sdk/telegram.js"; import { buildAgentMainSessionKey, DEFAULT_ACCOUNT_ID, diff --git a/src/plugin-sdk/account-resolution.ts b/src/plugin-sdk/account-resolution.ts index 4aceec2c945..cb819f57354 100644 --- a/src/plugin-sdk/account-resolution.ts +++ b/src/plugin-sdk/account-resolution.ts @@ -1,3 +1,16 @@ +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, + normalizeOptionalAccountId, +} from "../routing/session-key.js"; +export { normalizeE164, pathExists, resolveUserPath } from "../utils.js"; + /** Resolve an account by id, then fall back to the default account when the primary lacks credentials. */ export function resolveAccountWithDefaultFallback(params: { accountId?: string | null; diff --git a/src/plugin-sdk/acp-runtime.ts b/src/plugin-sdk/acp-runtime.ts new file mode 100644 index 00000000000..c50c36419bb --- /dev/null +++ b/src/plugin-sdk/acp-runtime.ts @@ -0,0 +1,6 @@ +// Public ACP runtime helpers for plugins that integrate with ACP control/session state. + +export { getAcpSessionManager } from "../acp/control-plane/manager.js"; +export { isAcpRuntimeError } from "../acp/runtime/errors.js"; +export { readAcpSessionEntry } from "../acp/runtime/session-meta.js"; +export type { AcpSessionStoreEntry } from "../acp/runtime/session-meta.js"; diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts new file mode 100644 index 00000000000..4eddbd51a29 --- /dev/null +++ b/src/plugin-sdk/agent-runtime.ts @@ -0,0 +1,28 @@ +// Public agent/model/runtime helpers for plugins that integrate with core agent flows. + +export * from "../agents/agent-scope.js"; +export * from "../agents/auth-profiles.js"; +export * from "../agents/current-time.js"; +export * from "../agents/defaults.js"; +export * from "../agents/identity-avatar.js"; +export * from "../agents/identity.js"; +export * from "../agents/model-auth-markers.js"; +export * from "../agents/model-auth.js"; +export * from "../agents/model-catalog.js"; +export * from "../agents/model-selection.js"; +export * from "../agents/pi-embedded-block-chunker.js"; +export * from "../agents/pi-embedded-utils.js"; +export * from "../agents/provider-id.js"; +export * from "../agents/schema/typebox.js"; +export * from "../agents/sglang-defaults.js"; +export * from "../agents/tools/common.js"; +export * from "../agents/tools/discord-actions-shared.js"; +export * from "../agents/tools/discord-actions.js"; +export * from "../agents/tools/telegram-actions.js"; +export * from "../agents/tools/web-guarded-fetch.js"; +export * from "../agents/tools/web-shared.js"; +export * from "../agents/tools/discord-actions-moderation-shared.js"; +export * from "../agents/tools/web-fetch-utils.js"; +export * from "../agents/vllm-defaults.js"; +export * from "../commands/agent.js"; +export * from "../tts/tts.js"; diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index 564bc86bc68..556e2a0c1c1 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -2,6 +2,13 @@ import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; +import { + collectAllowlistProviderGroupPolicyWarnings, + collectAllowlistProviderRestrictSendersWarnings, + collectOpenGroupPolicyConfiguredRouteWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, + collectOpenProviderGroupPolicyWarnings, +} from "../channels/plugins/group-policy-warnings.js"; import { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js"; import { getChannelPlugin } from "../channels/plugins/registry.js"; @@ -149,6 +156,15 @@ export function createScopedDmSecurityResolver< }); } +export { buildAccountScopedDmSecurityPolicy }; +export { + collectAllowlistProviderGroupPolicyWarnings, + collectAllowlistProviderRestrictSendersWarnings, + collectOpenGroupPolicyConfiguredRouteWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, + collectOpenProviderGroupPolicyWarnings, +}; + /** Read the effective WhatsApp allowlist through the active plugin contract. */ export function resolveWhatsAppConfigAllowFrom(params: { cfg: OpenClawConfig; diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts new file mode 100644 index 00000000000..4fda751b6cb --- /dev/null +++ b/src/plugin-sdk/channel-runtime.ts @@ -0,0 +1,53 @@ +// Shared channel/runtime helpers for plugins. Channel plugins should use this +// surface instead of reaching into src/channels or adjacent infra modules. + +export * from "../channels/ack-reactions.js"; +export * from "../channels/allow-from.js"; +export * from "../channels/allowlists/resolve-utils.js"; +export * from "../channels/allowlist-match.js"; +export * from "../channels/channel-config.js"; +export * from "../channels/chat-type.js"; +export * from "../channels/command-gating.js"; +export * from "../channels/conversation-label.js"; +export * from "../channels/draft-stream-controls.js"; +export * from "../channels/draft-stream-loop.js"; +export * from "../channels/inbound-debounce-policy.js"; +export * from "../channels/location.js"; +export * from "../channels/logging.js"; +export * from "../channels/mention-gating.js"; +export * from "../channels/native-command-session-targets.js"; +export * from "../channels/reply-prefix.js"; +export * from "../channels/run-state-machine.js"; +export * from "../channels/session.js"; +export * from "../channels/session-envelope.js"; +export * from "../channels/session-meta.js"; +export * from "../channels/status-reactions.js"; +export * from "../channels/targets.js"; +export * from "../channels/thread-binding-id.js"; +export * from "../channels/thread-bindings-messages.js"; +export * from "../channels/thread-bindings-policy.js"; +export * from "../channels/transport/stall-watchdog.js"; +export * from "../channels/typing.js"; +export * from "../channels/plugins/actions/reaction-message-id.js"; +export * from "../channels/plugins/actions/shared.js"; +export type * from "../channels/plugins/types.js"; +export * from "../channels/plugins/config-writes.js"; +export * from "../channels/plugins/directory-config.js"; +export * from "../channels/plugins/media-payload.js"; +export * from "../channels/plugins/normalize/signal.js"; +export * from "../channels/plugins/normalize/whatsapp.js"; +export * from "../channels/plugins/outbound/direct-text-media.js"; +export * from "../channels/plugins/outbound/interactive.js"; +export * from "../channels/plugins/status-issues/shared.js"; +export * from "../channels/plugins/whatsapp-heartbeat.js"; +export * from "../infra/outbound/send-deps.js"; +export * from "../utils/message-channel.js"; +export type { + InteractiveButtonStyle, + InteractiveReplyButton, + InteractiveReply, +} from "../interactive/payload.js"; +export { + normalizeInteractiveReply, + resolveInteractiveTextFallback, +} from "../interactive/payload.js"; diff --git a/src/plugin-sdk/cli-runtime.ts b/src/plugin-sdk/cli-runtime.ts new file mode 100644 index 00000000000..23a881da23a --- /dev/null +++ b/src/plugin-sdk/cli-runtime.ts @@ -0,0 +1,6 @@ +// Public CLI/output helpers for plugins that share terminal-facing command behavior. + +export * from "../cli/command-format.js"; +export * from "../cli/parse-duration.js"; +export * from "../cli/wait.js"; +export * from "../version.js"; diff --git a/src/plugin-sdk/config-runtime.ts b/src/plugin-sdk/config-runtime.ts new file mode 100644 index 00000000000..67b2ec82fee --- /dev/null +++ b/src/plugin-sdk/config-runtime.ts @@ -0,0 +1,42 @@ +// Shared config/runtime boundary for plugins that need config loading, +// config writes, or session-store helpers without importing src internals. + +export * from "../config/config.js"; +export * from "../config/markdown-tables.js"; +export * from "../config/group-policy.js"; +export * from "../config/runtime-group-policy.js"; +export * from "../config/commands.js"; +export * from "../config/discord-preview-streaming.js"; +export * from "../config/io.js"; +export * from "../config/telegram-custom-commands.js"; +export * from "../config/talk.js"; +export * from "../config/agent-limits.js"; +export * from "../cron/store.js"; +export * from "../sessions/model-overrides.js"; +export type * from "../config/types.slack.js"; +export { + loadSessionStore, + readSessionUpdatedAt, + recordSessionMetaFromInbound, + resolveSessionKey, + resolveStorePath, + updateLastRoute, + updateSessionStore, + type SessionResetMode, + type SessionScope, +} from "../config/sessions.js"; +export { resolveGroupSessionKey } from "../config/sessions/group.js"; +export { + evaluateSessionFreshness, + resolveChannelResetConfig, + resolveSessionResetPolicy, + resolveSessionResetType, + resolveThreadFlag, +} from "../config/sessions/reset.js"; +export { resolveSessionStoreEntry } from "../config/sessions/store.js"; +export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; +export { + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "../config/types.secrets.js"; diff --git a/src/plugin-sdk/conversation-runtime.ts b/src/plugin-sdk/conversation-runtime.ts new file mode 100644 index 00000000000..77380f6aa9a --- /dev/null +++ b/src/plugin-sdk/conversation-runtime.ts @@ -0,0 +1,41 @@ +// Public pairing/session-binding helpers for plugins that manage conversation ownership. + +export * from "../acp/persistent-bindings.route.js"; +export { + type BindingStatus, + type BindingTargetKind, + type ConversationRef, + SessionBindingError, + type SessionBindingAdapter, + type SessionBindingAdapterCapabilities, + type SessionBindingBindInput, + type SessionBindingCapabilities, + type SessionBindingPlacement, + type SessionBindingRecord, + type SessionBindingService, + type SessionBindingUnbindInput, + getSessionBindingService, + isSessionBindingError, + registerSessionBindingAdapter, + unregisterSessionBindingAdapter, +} from "../infra/outbound/session-binding-service.js"; +export * from "../pairing/pairing-challenge.js"; +export * from "../pairing/pairing-messages.js"; +export * from "../pairing/pairing-store.js"; +export { + buildPluginBindingApprovalCustomId, + buildPluginBindingDeclinedText, + buildPluginBindingErrorText, + buildPluginBindingResolvedText, + buildPluginBindingUnavailableText, + detachPluginConversationBinding, + getCurrentPluginConversationBinding, + hasShownPluginBindingFallbackNotice, + isPluginOwnedBindingMetadata, + isPluginOwnedSessionBindingRecord, + markPluginBindingFallbackNoticeShown, + parsePluginBindingApprovalCustomId, + requestPluginConversationBinding, + resolvePluginConversationBindingApproval, + toPluginConversationBinding, +} from "../plugins/conversation-binding.js"; diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index d15f5091b9d..b31c796e2d6 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,6 +1,18 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; +export type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js"; +export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; +export type { ResolvedDiscordAccount } from "../../extensions/discord/src/accounts.js"; +export type { + DiscordSendComponents, + DiscordSendEmbeds, +} from "../../extensions/discord/src/send.shared.js"; +export type { + ThreadBindingManager, + ThreadBindingRecord, + ThreadBindingTargetKind, +} from "../../extensions/discord/src/monitor/thread-bindings.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -44,3 +56,77 @@ export { buildComputedAccountStatusSnapshot, buildTokenChannelStatusSummary, } from "./status-helpers.js"; + +export { + createDiscordActionGate, + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, +} from "../../extensions/discord/src/accounts.js"; +export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; +export { + looksLikeDiscordTargetId, + normalizeDiscordMessagingTarget, + normalizeDiscordOutboundTarget, +} from "../../extensions/discord/src/normalize.js"; +export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; +export { collectDiscordStatusIssues } from "../../extensions/discord/src/status-issues.js"; +export { + DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, + DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, +} from "../../extensions/discord/src/monitor/timeouts.js"; +export { normalizeExplicitDiscordSessionKey } from "../../extensions/discord/src/session-key-normalization.js"; +export { + autoBindSpawnedDiscordSubagent, + listThreadBindingsBySessionKey, + unbindThreadBindingsBySessionKey, +} from "../../extensions/discord/src/monitor/thread-bindings.js"; +export { getGateway } from "../../extensions/discord/src/monitor/gateway-registry.js"; +export { getPresence } from "../../extensions/discord/src/monitor/presence-cache.js"; +export { readDiscordComponentSpec } from "../../extensions/discord/src/components.js"; +export { resolveDiscordChannelId } from "../../extensions/discord/src/targets.js"; +export { + addRoleDiscord, + banMemberDiscord, + createChannelDiscord, + createScheduledEventDiscord, + createThreadDiscord, + deleteChannelDiscord, + deleteMessageDiscord, + editChannelDiscord, + editMessageDiscord, + fetchChannelInfoDiscord, + fetchChannelPermissionsDiscord, + fetchMemberInfoDiscord, + fetchMessageDiscord, + fetchReactionsDiscord, + fetchRoleInfoDiscord, + fetchVoiceStatusDiscord, + hasAnyGuildPermissionDiscord, + kickMemberDiscord, + listGuildChannelsDiscord, + listGuildEmojisDiscord, + listPinsDiscord, + listScheduledEventsDiscord, + listThreadsDiscord, + moveChannelDiscord, + pinMessageDiscord, + reactMessageDiscord, + readMessagesDiscord, + removeChannelPermissionDiscord, + removeOwnReactionsDiscord, + removeReactionDiscord, + removeRoleDiscord, + searchMessagesDiscord, + sendDiscordComponentMessage, + sendMessageDiscord, + sendPollDiscord, + sendStickerDiscord, + sendVoiceMessageDiscord, + setChannelPermissionDiscord, + timeoutMemberDiscord, + unpinMessageDiscord, + uploadEmojiDiscord, + uploadStickerDiscord, +} from "../../extensions/discord/src/send.js"; +export { discordMessageActions } from "../../extensions/discord/src/channel-actions.js"; diff --git a/src/plugin-sdk/gateway-runtime.ts b/src/plugin-sdk/gateway-runtime.ts new file mode 100644 index 00000000000..f1ef78ef14c --- /dev/null +++ b/src/plugin-sdk/gateway-runtime.ts @@ -0,0 +1,6 @@ +// Public gateway/client helpers for plugins that talk to the host gateway surface. + +export * from "../gateway/channel-status-patches.js"; +export { GatewayClient } from "../gateway/client.js"; +export { createOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js"; +export type { EventFrame } from "../gateway/protocol/index.js"; diff --git a/src/plugin-sdk/hook-runtime.ts b/src/plugin-sdk/hook-runtime.ts new file mode 100644 index 00000000000..dd67f98cf04 --- /dev/null +++ b/src/plugin-sdk/hook-runtime.ts @@ -0,0 +1,5 @@ +// Public hook helpers for plugins that need the shared internal/webhook hook pipeline. + +export * from "../hooks/fire-and-forget.js"; +export * from "../hooks/internal-hooks.js"; +export * from "../hooks/message-hook-mappers.js"; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index a974910e680..5481c117be6 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -40,3 +40,4 @@ export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { collectStatusIssuesFromLastError } from "./status-helpers.js"; +export { sendMessageIMessage } from "../../extensions/imessage/src/send.js"; diff --git a/src/plugin-sdk/infra-runtime.ts b/src/plugin-sdk/infra-runtime.ts new file mode 100644 index 00000000000..dd75ac4fea2 --- /dev/null +++ b/src/plugin-sdk/infra-runtime.ts @@ -0,0 +1,39 @@ +// Public runtime/transport helpers for plugins that need shared infra behavior. + +export * from "../infra/backoff.js"; +export * from "../infra/channel-activity.js"; +export * from "../infra/dedupe.js"; +export * from "../infra/diagnostic-events.js"; +export * from "../infra/diagnostic-flags.js"; +export * from "../infra/env.js"; +export * from "../infra/errors.js"; +export * from "../infra/exec-approval-command-display.ts"; +export * from "../infra/exec-approval-reply.ts"; +export * from "../infra/exec-approval-session-target.ts"; +export * from "../infra/exec-approvals.ts"; +export * from "../infra/fetch.js"; +export * from "../infra/file-lock.js"; +export * from "../infra/format-time/format-duration.ts"; +export * from "../infra/fs-safe.ts"; +export * from "../infra/heartbeat-events.ts"; +export * from "../infra/heartbeat-visibility.ts"; +export * from "../infra/home-dir.js"; +export * from "../infra/http-body.js"; +export * from "../infra/json-files.js"; +export * from "../infra/map-size.js"; +export * from "../infra/net/hostname.ts"; +export * from "../infra/net/fetch-guard.js"; +export * from "../infra/net/proxy-env.js"; +export * from "../infra/net/proxy-fetch.js"; +export * from "../infra/net/ssrf.js"; +export * from "../infra/outbound/identity.js"; +export * from "../infra/retry.js"; +export * from "../infra/retry-policy.js"; +export * from "../infra/scp-host.ts"; +export * from "../infra/secret-file.js"; +export * from "../infra/secure-random.js"; +export * from "../infra/system-events.js"; +export * from "../infra/system-message.ts"; +export * from "../infra/tmp-openclaw-dir.js"; +export * from "../infra/transport-ready.js"; +export * from "../infra/wsl.ts"; diff --git a/src/plugin-sdk/json-store.ts b/src/plugin-sdk/json-store.ts index faff8f64e59..b95ee5b819b 100644 --- a/src/plugin-sdk/json-store.ts +++ b/src/plugin-sdk/json-store.ts @@ -1,7 +1,14 @@ import fs from "node:fs"; +import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; import { writeJsonAtomic } from "../infra/json-files.js"; import { safeParseJson } from "../utils.js"; +/** Read small JSON blobs synchronously for token/state caches. */ +export { loadJsonFile }; + +/** Persist small JSON blobs synchronously with restrictive permissions. */ +export { saveJsonFile }; + /** Read JSON from disk and fall back cleanly when the file is missing or invalid. */ export async function readJsonFileWithFallback( filePath: string, diff --git a/src/plugin-sdk/media-runtime.ts b/src/plugin-sdk/media-runtime.ts new file mode 100644 index 00000000000..2f2d81b0d46 --- /dev/null +++ b/src/plugin-sdk/media-runtime.ts @@ -0,0 +1,21 @@ +// Public media/payload helpers for plugins that fetch, transform, or send attachments. + +export * from "../media/audio.js"; +export * from "../media/constants.js"; +export * from "../media/fetch.js"; +export * from "../media/ffmpeg-exec.js"; +export * from "../media/ffmpeg-limits.js"; +export * from "../media/image-ops.js"; +export * from "../media/inbound-path-policy.js"; +export * from "../media/load-options.js"; +export * from "../media/local-roots.js"; +export * from "../media/mime.js"; +export * from "../media/outbound-attachment.js"; +export * from "../media/png-encode.ts"; +export * from "../media/store.js"; +export * from "../media/temp-files.js"; +export * from "../media-understanding/audio-preflight.ts"; +export * from "../media-understanding/defaults.js"; +export * from "../media-understanding/providers/image-runtime.ts"; +export * from "../media-understanding/runner.js"; +export * from "../polls.js"; diff --git a/src/plugin-sdk/plugin-runtime.ts b/src/plugin-sdk/plugin-runtime.ts new file mode 100644 index 00000000000..ecc80f8f224 --- /dev/null +++ b/src/plugin-sdk/plugin-runtime.ts @@ -0,0 +1,6 @@ +// Public plugin-command/hook helpers for plugins that extend shared command or hook flows. + +export * from "../plugins/commands.js"; +export * from "../plugins/hook-runner-global.js"; +export * from "../plugins/interactive.js"; +export * from "../plugins/types.js"; diff --git a/src/plugin-sdk/process-runtime.ts b/src/plugin-sdk/process-runtime.ts new file mode 100644 index 00000000000..826ed2d1197 --- /dev/null +++ b/src/plugin-sdk/process-runtime.ts @@ -0,0 +1,3 @@ +// Public process helpers for plugins that spawn or probe local commands. + +export * from "../process/exec.js"; diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts new file mode 100644 index 00000000000..40669e51d97 --- /dev/null +++ b/src/plugin-sdk/provider-auth.ts @@ -0,0 +1,43 @@ +// Public auth/onboarding helpers for provider plugins. + +export type { OpenClawConfig } from "../config/config.js"; +export type { SecretInput } from "../config/types.secrets.js"; +export type { ProviderAuthResult } from "../plugins/types.js"; +export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/types.js"; + +export { + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, + ensureAuthProfileStore, + listProfilesForProvider, + suggestOAuthProfileIdForLegacyDefault, + upsertAuthProfile, +} from "../agents/auth-profiles.js"; +export { + MINIMAX_OAUTH_MARKER, + resolveNonEnvSecretRefApiKeyMarker, +} from "../agents/model-auth-markers.js"; +export { + formatApiKeyPreview, + normalizeApiKeyInput, + validateApiKeyInput, +} from "../commands/auth-choice.api-key.js"; +export { + ensureApiKeyFromOptionEnvOrPrompt, + normalizeSecretInputModeInput, + promptSecretRefForSetup, + resolveSecretInputModeForEnvSelection, +} from "../commands/auth-choice.apply-helpers.js"; +export { buildTokenProfileId, validateAnthropicSetupToken } from "../commands/auth-token.js"; +export { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js"; +export { applyAuthProfileConfig } from "../commands/onboard-auth.js"; +export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; +export { loginOpenAICodexOAuth } from "../commands/openai-codex-oauth.js"; +export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; +export { coerceSecretRef } from "../config/types.secrets.js"; +export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; +export { resolveRequiredHomeDir } from "../infra/home-dir.js"; +export { + normalizeOptionalSecretInput, + normalizeSecretInput, +} from "../utils/normalize-secret-input.js"; diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts new file mode 100644 index 00000000000..5221daec1cd --- /dev/null +++ b/src/plugin-sdk/provider-models.ts @@ -0,0 +1,86 @@ +// Public model/catalog helpers for provider plugins. + +export type { + ModelApi, + ModelDefinitionConfig, + ModelProviderConfig, +} from "../config/types.models.js"; +export type { ProviderPlugin } from "../plugins/types.js"; + +export { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; +export { normalizeModelCompat } from "../agents/model-compat.js"; +export { normalizeProviderId } from "../agents/provider-id.js"; + +export { + applyGoogleGeminiModelDefault, + GOOGLE_GEMINI_DEFAULT_MODEL, +} from "../commands/google-gemini-model-default.js"; +export { applyOpenAIConfig, OPENAI_DEFAULT_MODEL } from "../commands/openai-model-default.js"; +export { OPENCODE_GO_DEFAULT_MODEL_REF } from "../commands/opencode-go-model-default.js"; +export { OPENCODE_ZEN_DEFAULT_MODEL } from "../commands/opencode-zen-model-default.js"; +export { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; + +export * from "../commands/onboard-auth.models.js"; + +export { + buildCloudflareAiGatewayModelDefinition, + CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, + resolveCloudflareAiGatewayBaseUrl, +} from "../agents/cloudflare-ai-gateway.js"; +export { + discoverHuggingfaceModels, + HUGGINGFACE_BASE_URL, + HUGGINGFACE_MODEL_CATALOG, + buildHuggingfaceModelDefinition, +} from "../agents/huggingface-models.js"; +export { discoverKilocodeModels } from "../agents/kilocode-models.js"; +export { resolveOllamaApiBase } from "../agents/ollama-models.js"; +export { + buildSyntheticModelDefinition, + SYNTHETIC_BASE_URL, + SYNTHETIC_DEFAULT_MODEL_REF, + SYNTHETIC_MODEL_CATALOG, +} from "../agents/synthetic-models.js"; +export { + buildTogetherModelDefinition, + TOGETHER_BASE_URL, + TOGETHER_MODEL_CATALOG, +} from "../agents/together-models.js"; +export { + discoverVeniceModels, + VENICE_BASE_URL, + VENICE_DEFAULT_MODEL_REF, + VENICE_MODEL_CATALOG, + buildVeniceModelDefinition, +} from "../agents/venice-models.js"; +export { + BYTEPLUS_BASE_URL, + BYTEPLUS_CODING_BASE_URL, + BYTEPLUS_CODING_MODEL_CATALOG, + BYTEPLUS_MODEL_CATALOG, + buildBytePlusModelDefinition, +} from "../agents/byteplus-models.js"; +export { + DOUBAO_BASE_URL, + DOUBAO_CODING_BASE_URL, + DOUBAO_CODING_MODEL_CATALOG, + DOUBAO_MODEL_CATALOG, + buildDoubaoModelDefinition, +} from "../agents/doubao-models.js"; +export { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; +export { VLLM_DEFAULT_BASE_URL } from "../agents/vllm-defaults.js"; +export { SGLANG_DEFAULT_BASE_URL } from "../agents/sglang-defaults.js"; +export { + KILOCODE_BASE_URL, + KILOCODE_DEFAULT_CONTEXT_WINDOW, + KILOCODE_DEFAULT_COST, + KILOCODE_DEFAULT_MODEL_REF, + KILOCODE_DEFAULT_MAX_TOKENS, + KILOCODE_DEFAULT_MODEL_ID, + KILOCODE_DEFAULT_MODEL_NAME, + KILOCODE_MODEL_CATALOG, +} from "../providers/kilocode-shared.js"; +export { + discoverVercelAiGatewayModels, + VERCEL_AI_GATEWAY_BASE_URL, +} from "../agents/vercel-ai-gateway.js"; diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts new file mode 100644 index 00000000000..b2175f092fe --- /dev/null +++ b/src/plugin-sdk/provider-onboard.ts @@ -0,0 +1,16 @@ +// Public config patch helpers for provider onboarding flows. + +export type { OpenClawConfig } from "../config/config.js"; +export type { + ModelApi, + ModelDefinitionConfig, + ModelProviderConfig, +} from "../config/types.models.js"; +export { + applyAgentDefaultModelPrimary, + applyOnboardAuthAgentModelsAndProviders, + applyProviderConfigWithDefaultModel, + applyProviderConfigWithDefaultModels, + applyProviderConfigWithModelCatalog, +} from "../commands/onboard-auth.config-shared.js"; +export { ensureModelAllowlistEntry } from "../commands/model-allowlist.js"; diff --git a/src/plugin-sdk/provider-stream.ts b/src/plugin-sdk/provider-stream.ts new file mode 100644 index 00000000000..19b8fe76092 --- /dev/null +++ b/src/plugin-sdk/provider-stream.ts @@ -0,0 +1,17 @@ +// Public stream-wrapper helpers for provider plugins. + +export { + createKilocodeWrapper, + createOpenRouterSystemCacheWrapper, + createOpenRouterWrapper, + isProxyReasoningUnsupported, +} from "../agents/pi-embedded-runner/proxy-stream-wrappers.js"; +export { + createMoonshotThinkingWrapper, + resolveMoonshotThinkingType, +} from "../agents/pi-embedded-runner/moonshot-stream-wrappers.js"; +export { createZaiToolStreamWrapper } from "../agents/pi-embedded-runner/zai-stream-wrappers.js"; +export { + getOpenRouterModelCapabilities, + loadOpenRouterModelCapabilities, +} from "../agents/pi-embedded-runner/openrouter-model-capabilities.js"; diff --git a/src/plugin-sdk/provider-usage.ts b/src/plugin-sdk/provider-usage.ts new file mode 100644 index 00000000000..33757596965 --- /dev/null +++ b/src/plugin-sdk/provider-usage.ts @@ -0,0 +1,21 @@ +// Public usage fetch helpers for provider plugins. + +export type { + ProviderUsageSnapshot, + UsageProviderId, + UsageWindow, +} from "../infra/provider-usage.types.js"; + +export { + fetchClaudeUsage, + fetchCodexUsage, + fetchGeminiUsage, + fetchMinimaxUsage, + fetchZaiUsage, +} from "../infra/provider-usage.fetch.js"; +export { clampPercent, PROVIDER_LABELS } from "../infra/provider-usage.shared.js"; +export { + buildUsageErrorSnapshot, + buildUsageHttpErrorSnapshot, + fetchJson, +} from "../infra/provider-usage.fetch.shared.js"; diff --git a/src/plugin-sdk/provider-web-search.ts b/src/plugin-sdk/provider-web-search.ts new file mode 100644 index 00000000000..551c3d5ed5d --- /dev/null +++ b/src/plugin-sdk/provider-web-search.ts @@ -0,0 +1,18 @@ +// Public web-search registration helpers for provider plugins. + +export { + createPluginBackedWebSearchProvider, + getScopedCredentialValue, + getTopLevelCredentialValue, + setScopedCredentialValue, + setTopLevelCredentialValue, +} from "../agents/tools/web-search-plugin-factory.js"; +export { withTrustedWebToolsEndpoint } from "../agents/tools/web-guarded-fetch.js"; +export { + DEFAULT_CACHE_TTL_MINUTES, + normalizeCacheKey, + readCache, + readResponseText, + resolveCacheTtlMs, + writeCache, +} from "../agents/tools/web-shared.js"; diff --git a/src/plugin-sdk/qwen-portal-auth.ts b/src/plugin-sdk/qwen-portal-auth.ts index 01533a77e8c..f6cde98b90f 100644 --- a/src/plugin-sdk/qwen-portal-auth.ts +++ b/src/plugin-sdk/qwen-portal-auth.ts @@ -8,4 +8,7 @@ export type { ProviderAuthContext, ProviderCatalogContext, } from "../plugins/types.js"; +export { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; +export { QWEN_OAUTH_MARKER } from "../agents/model-auth-markers.js"; +export { refreshQwenPortalCredentials } from "../providers/qwen-portal-oauth.js"; export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugin-sdk/reply-runtime.ts b/src/plugin-sdk/reply-runtime.ts new file mode 100644 index 00000000000..689cf4cdba7 --- /dev/null +++ b/src/plugin-sdk/reply-runtime.ts @@ -0,0 +1,31 @@ +// Shared agent/reply runtime helpers for channel plugins. Keep channel plugins +// off direct src/auto-reply imports by routing common reply primitives here. + +export * from "../auto-reply/chunk.js"; +export * from "../auto-reply/command-auth.js"; +export * from "../auto-reply/command-detection.js"; +export * from "../auto-reply/commands-registry.js"; +export * from "../auto-reply/dispatch.js"; +export * from "../auto-reply/group-activation.js"; +export * from "../auto-reply/heartbeat.js"; +export * from "../auto-reply/heartbeat-reply-payload.js"; +export * from "../auto-reply/inbound-debounce.js"; +export * from "../auto-reply/reply.js"; +export * from "../auto-reply/tokens.js"; +export * from "../auto-reply/envelope.js"; +export * from "../auto-reply/reply/history.js"; +export * from "../auto-reply/reply/abort.js"; +export * from "../auto-reply/reply/btw-command.js"; +export * from "../auto-reply/reply/commands-models.js"; +export * from "../auto-reply/reply/inbound-dedupe.js"; +export * from "../auto-reply/reply/inbound-context.js"; +export * from "../auto-reply/reply/mentions.js"; +export * from "../auto-reply/reply/reply-dispatcher.js"; +export * from "../auto-reply/reply/reply-reference.js"; +export * from "../auto-reply/reply/provider-dispatcher.js"; +export * from "../auto-reply/reply/model-selection.js"; +export * from "../auto-reply/reply/commands-info.js"; +export * from "../auto-reply/skill-commands.js"; +export * from "../auto-reply/status.js"; +export type { ReplyPayload } from "../auto-reply/types.js"; +export type { FinalizedMsgContext, MsgContext } from "../auto-reply/templating.js"; diff --git a/src/plugin-sdk/routing.ts b/src/plugin-sdk/routing.ts index 921d085ae55..144304a607c 100644 --- a/src/plugin-sdk/routing.ts +++ b/src/plugin-sdk/routing.ts @@ -1,6 +1,31 @@ export { buildAgentSessionKey, + deriveLastRoutePolicy, + resolveAgentRoute, + resolveInboundLastRouteSessionKey, + type ResolvedAgentRoute, type RoutePeer, type RoutePeerKind, } from "../routing/resolve-route.js"; -export { resolveThreadSessionKeys } from "../routing/session-key.js"; +export { + buildAgentMainSessionKey, + DEFAULT_ACCOUNT_ID, + DEFAULT_MAIN_KEY, + buildGroupHistoryKey, + isCronSessionKey, + isSubagentSessionKey, + normalizeAccountId, + normalizeAgentId, + normalizeMainKey, + normalizeOptionalAccountId, + parseAgentSessionKey, + resolveAgentIdFromSessionKey, + resolveThreadSessionKeys, + sanitizeAgentId, +} from "../routing/session-key.js"; +export { resolveAccountEntry } from "../routing/account-lookup.js"; +export { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js"; +export { + formatSetExplicitDefaultInstruction, + formatSetExplicitDefaultToConfiguredInstruction, +} from "../routing/default-account-warnings.js"; diff --git a/src/plugin-sdk/runtime-env.ts b/src/plugin-sdk/runtime-env.ts new file mode 100644 index 00000000000..c216bbbfbe6 --- /dev/null +++ b/src/plugin-sdk/runtime-env.ts @@ -0,0 +1,21 @@ +// Shared process/runtime utilities for plugins. This is the public boundary for +// logger wiring, runtime env shims, and global verbose console helpers. + +export type { RuntimeEnv } from "../runtime.js"; +export { createNonExitingRuntime, defaultRuntime } from "../runtime.js"; +export { + danger, + info, + isVerbose, + isYes, + logVerbose, + logVerboseConsole, + setVerbose, + setYes, + shouldLogVerbose, + success, + warn, +} from "../globals.js"; +export * from "../logging.js"; +export { waitForAbortSignal } from "../infra/abort-signal.js"; +export { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; diff --git a/src/plugin-sdk/runtime-store.ts b/src/plugin-sdk/runtime-store.ts index 67e8bb3644c..34257c918b0 100644 --- a/src/plugin-sdk/runtime-store.ts +++ b/src/plugin-sdk/runtime-store.ts @@ -1,3 +1,5 @@ +export type { PluginRuntime } from "../plugins/runtime/types.js"; + /** Create a tiny mutable runtime slot with strict access when the runtime has not been initialized. */ export function createPluginRuntimeStore(errorMessage: string): { setRuntime: (next: T) => void; diff --git a/src/plugin-sdk/runtime.ts b/src/plugin-sdk/runtime.ts index 75b6f955dc7..ec39c97a549 100644 --- a/src/plugin-sdk/runtime.ts +++ b/src/plugin-sdk/runtime.ts @@ -1,5 +1,23 @@ import { format } from "node:util"; import type { RuntimeEnv } from "../runtime.js"; +export type { RuntimeEnv } from "../runtime.js"; +export { createNonExitingRuntime, defaultRuntime } from "../runtime.js"; +export { + danger, + info, + isVerbose, + isYes, + logVerbose, + logVerboseConsole, + setVerbose, + setYes, + shouldLogVerbose, + success, + warn, +} from "../globals.js"; +export * from "../logging.js"; +export { waitForAbortSignal } from "../infra/abort-signal.js"; +export { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; type LoggerLike = { info: (message: string) => void; diff --git a/src/plugin-sdk/security-runtime.ts b/src/plugin-sdk/security-runtime.ts new file mode 100644 index 00000000000..4b7c42bbef3 --- /dev/null +++ b/src/plugin-sdk/security-runtime.ts @@ -0,0 +1,6 @@ +// Public security/policy helpers for plugins that need shared trust and DM gating logic. + +export * from "../security/channel-metadata.js"; +export * from "../security/dm-policy-shared.js"; +export * from "../security/external-content.js"; +export * from "../security/safe-regex.js"; diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts index e77af2904c3..a2a7cf5c302 100644 --- a/src/plugin-sdk/setup.ts +++ b/src/plugin-sdk/setup.ts @@ -7,11 +7,16 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export type { ChannelSetupInput } from "../channels/plugins/types.core.js"; export type { ChannelSetupDmPolicy } from "../channels/plugins/setup-wizard-types.js"; +export type { ChannelSetupWizardAllowFromEntry } from "../channels/plugins/setup-wizard.js"; export type { ChannelSetupWizard } from "../channels/plugins/setup-wizard.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +export { formatCliCommand } from "../cli/command-format.js"; +export { detectBinary } from "../commands/onboard-helpers.js"; +export { installSignalCli } from "../commands/signal-install.js"; export { formatDocsLink } from "../terminal/links.js"; export { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; +export { normalizeE164, pathExists } from "../utils.js"; export { applyAccountNameToChannelSection, @@ -23,10 +28,22 @@ export { addWildcardAllowFrom, buildSingleChannelSecretPromptState, mergeAllowFromEntries, + normalizeAllowFromEntries, + noteChannelLookupFailure, + noteChannelLookupSummary, + parseMentionOrPrefixedId, + parseSetupEntriesAllowingWildcard, + parseSetupEntriesWithParser, patchChannelConfigForAccount, + promptLegacyChannelAllowFrom, + promptParsedAllowFromForScopedChannel, promptSingleChannelSecretInput, + promptResolvedAllowFrom, resolveSetupAccountId, runSingleChannelSecretStep, + setAccountGroupPolicyForChannel, + setChannelDmPolicyWithAllowFrom, + setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index 8fd6fd2afd0..f7d3ec2d84d 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,5 +1,7 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; +export type { OpenClawConfig } from "../config/config.js"; export type { SignalAccountConfig } from "../config/types.js"; +export type { ResolvedSignalAccount } from "../../extensions/signal/src/accounts.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -40,3 +42,16 @@ export { collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, } from "./status-helpers.js"; + +export { + listEnabledSignalAccounts, + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, +} from "../../extensions/signal/src/accounts.js"; +export { resolveSignalReactionLevel } from "../../extensions/signal/src/reaction-level.js"; +export { + removeReactionSignal, + sendReactionSignal, +} from "../../extensions/signal/src/send-reactions.js"; +export { sendMessageSignal } from "../../extensions/signal/src/send.js"; diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index f7533b95687..b883aebac95 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -1,5 +1,7 @@ export type { OpenClawConfig } from "../config/config.js"; export type { SlackAccountConfig } from "../config/types.slack.js"; +export type { InspectedSlackAccount } from "../../extensions/slack/src/account-inspect.js"; +export type { ResolvedSlackAccount } from "../../extensions/slack/src/accounts.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -43,3 +45,40 @@ export { } from "../channels/plugins/group-mentions.js"; export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; export { buildComputedAccountStatusSnapshot } from "./status-helpers.js"; + +export { + listEnabledSlackAccounts, + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + resolveSlackReplyToMode, +} from "../../extensions/slack/src/accounts.js"; +export { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/src/interactive-replies.js"; +export { inspectSlackAccount } from "../../extensions/slack/src/account-inspect.js"; +export { parseSlackTarget, resolveSlackChannelId } from "./slack-targets.js"; +export { + extractSlackToolSend, + listSlackMessageActions, +} from "../../extensions/slack/src/message-actions.js"; +export { buildSlackThreadingToolContext } from "../../extensions/slack/src/threading-tool-context.js"; +export { parseSlackBlocksInput } from "../../extensions/slack/src/blocks-input.js"; +export { handleSlackHttpRequest } from "../../extensions/slack/src/http/index.js"; +export { sendMessageSlack } from "../../extensions/slack/src/send.js"; +export { + deleteSlackMessage, + downloadSlackFile, + editSlackMessage, + getSlackMemberInfo, + listSlackEmojis, + listSlackPins, + listSlackReactions, + pinSlackMessage, + reactSlackMessage, + readSlackMessages, + removeOwnSlackReactions, + removeSlackReaction, + sendSlackMessage, + unpinSlackMessage, +} from "../../extensions/slack/src/actions.js"; +export { recordSlackThreadParticipation } from "../../extensions/slack/src/sent-thread-cache.js"; +export { handleSlackMessageAction } from "./slack-message-actions.js"; diff --git a/src/plugin-sdk/speech.ts b/src/plugin-sdk/speech.ts new file mode 100644 index 00000000000..3fb9758ffdc --- /dev/null +++ b/src/plugin-sdk/speech.ts @@ -0,0 +1,7 @@ +// Public speech-provider builders for bundled or third-party plugins. + +export { buildElevenLabsSpeechProvider } from "../tts/providers/elevenlabs.js"; +export { buildMicrosoftSpeechProvider } from "../tts/providers/microsoft.js"; +export { buildOpenAISpeechProvider } from "../tts/providers/openai.js"; +export { parseTtsDirectives } from "../tts/tts-core.js"; +export type { SpeechVoiceOption } from "../tts/provider-types.js"; diff --git a/src/plugin-sdk/state-paths.ts b/src/plugin-sdk/state-paths.ts new file mode 100644 index 00000000000..aeae39fa1f1 --- /dev/null +++ b/src/plugin-sdk/state-paths.ts @@ -0,0 +1,3 @@ +// Public state/config path helpers for plugins that persist small caches. + +export { resolveOAuthDir, resolveStateDir, STATE_DIR } from "../config/paths.js"; diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 6551baffe87..cb26a82cb13 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -12,9 +12,18 @@ export type { TelegramActionConfig, TelegramNetworkConfig, } from "../config/types.js"; +export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; +export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js"; +export type { TelegramProbe } from "../../extensions/telegram/src/probe.js"; +export type { + TelegramButtonStyle, + TelegramInlineButtons, +} from "../../extensions/telegram/src/button-types.js"; +export type { StickerMetadata } from "../../extensions/telegram/src/bot/types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +export { parseTelegramTopicConversation } from "../acp/conversation-id.js"; export { PAIRING_APPROVED_MESSAGE, @@ -28,6 +37,7 @@ export { } from "./channel-plugin-common.js"; export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; +export { resolveTelegramPollVisibility } from "../poll-params.js"; export { projectCredentialSnapshotFields, @@ -49,3 +59,57 @@ export { export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; export { buildTokenChannelStatusSummary } from "./status-helpers.js"; + +export { + createTelegramActionGate, + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramPollActionGateState, + resolveTelegramAccount, +} from "../../extensions/telegram/src/accounts.js"; +export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; +export { + looksLikeTelegramTargetId, + normalizeTelegramMessagingTarget, +} from "../../extensions/telegram/src/normalize.js"; +export { + parseTelegramReplyToMessageId, + parseTelegramThreadId, +} from "../../extensions/telegram/src/outbound-params.js"; +export { + isNumericTelegramUserId, + normalizeTelegramAllowFromEntry, +} from "../../extensions/telegram/src/allow-from.js"; +export { fetchTelegramChatId } from "../../extensions/telegram/src/api-fetch.js"; +export { + resolveTelegramInlineButtonsScope, + resolveTelegramTargetChatType, +} from "../../extensions/telegram/src/inline-buttons.js"; +export { resolveTelegramReactionLevel } from "../../extensions/telegram/src/reaction-level.js"; +export { + createForumTopicTelegram, + deleteMessageTelegram, + editForumTopicTelegram, + editMessageTelegram, + reactMessageTelegram, + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, +} from "../../extensions/telegram/src/send.js"; +export { getCacheStats, searchStickers } from "../../extensions/telegram/src/sticker-cache.js"; +export { resolveTelegramToken } from "../../extensions/telegram/src/token.js"; +export { telegramMessageActions } from "../../extensions/telegram/src/channel-actions.js"; +export { collectTelegramStatusIssues } from "../../extensions/telegram/src/status-issues.js"; +export { sendTelegramPayloadMessages } from "../../extensions/telegram/src/outbound-adapter.js"; +export { + buildBrowseProvidersButton, + buildModelsKeyboard, + buildProviderKeyboard, + calculateTotalPages, + getModelsPageSize, + type ProviderInfo, +} from "../../extensions/telegram/src/model-buttons.js"; +export { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, +} from "../../extensions/telegram/src/exec-approvals.js"; diff --git a/src/plugin-sdk/test-utils.ts b/src/plugin-sdk/test-utils.ts index 78307f694a6..5d825813d0e 100644 --- a/src/plugin-sdk/test-utils.ts +++ b/src/plugin-sdk/test-utils.ts @@ -6,3 +6,4 @@ export type { ChannelAccountSnapshot, ChannelGatewayContext } from "../channels/ export type { OpenClawConfig } from "../config/config.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { RuntimeEnv } from "../runtime.js"; +export type { MockFn } from "../test-utils/vitest-mock-fn.js"; diff --git a/src/plugin-sdk/text-runtime.ts b/src/plugin-sdk/text-runtime.ts new file mode 100644 index 00000000000..bfdb2db690f --- /dev/null +++ b/src/plugin-sdk/text-runtime.ts @@ -0,0 +1,23 @@ +// Public shared text/formatting helpers for plugins that parse or rewrite message text. + +export * from "../logger.js"; +export * from "../logging/diagnostic.js"; +export * from "../logging/logger.js"; +export * from "../logging/redact.js"; +export * from "../logging/redact-identifier.js"; +export * from "../markdown/ir.js"; +export * from "../markdown/render.js"; +export * from "../markdown/tables.js"; +export * from "../markdown/whatsapp.js"; +export * from "../shared/global-singleton.js"; +export * from "../shared/string-normalization.js"; +export * from "../shared/string-sample.js"; +export * from "../shared/text/assistant-visible-text.js"; +export * from "../shared/text/code-regions.js"; +export * from "../shared/text/reasoning-tags.js"; +export * from "../terminal/safe-text.js"; +export * from "../utils.js"; +export * from "../utils/chunk-items.js"; +export * from "../utils/fetch-timeout.js"; +export * from "../utils/reaction-level.js"; +export * from "../utils/with-timeout.js"; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index df814fa04eb..3727cc802ec 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -1,6 +1,14 @@ export type { ChannelMessageActionName } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; +export type { + WebChannelStatus, + WebMonitorTuning, +} from "../../extensions/whatsapp/src/auto-reply.js"; +export type { + WebInboundMessage, + WebListenerCloseReason, +} from "../../extensions/whatsapp/src/inbound.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -36,6 +44,7 @@ export { } from "../channels/plugins/group-policy-warnings.js"; export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js"; +export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, @@ -56,3 +65,48 @@ export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js export { createActionGate, readStringParam } from "../agents/tools/common.js"; export { createPluginRuntimeStore } from "./runtime-store.js"; export { normalizeE164 } from "../utils.js"; + +export { + hasAnyWhatsAppAuth, + listEnabledWhatsAppAccounts, + resolveWhatsAppAccount, +} from "../../extensions/whatsapp/src/accounts.js"; +export { + WA_WEB_AUTH_DIR, + logWebSelfId, + logoutWeb, + pickWebChannel, + webAuthExists, +} from "../../extensions/whatsapp/src/auth-store.js"; +export { + DEFAULT_WEB_MEDIA_BYTES, + HEARTBEAT_PROMPT, + HEARTBEAT_TOKEN, + monitorWebChannel, + resolveHeartbeatRecipients, + runWebHeartbeatOnce, +} from "../../extensions/whatsapp/src/auto-reply.js"; +export { + extractMediaPlaceholder, + extractText, + monitorWebInbox, +} from "../../extensions/whatsapp/src/inbound.js"; +export { loginWeb } from "../../extensions/whatsapp/src/login.js"; +export { + getDefaultLocalRoots, + loadWebMedia, + loadWebMediaRaw, + optimizeImageToJpeg, +} from "../../extensions/whatsapp/src/media.js"; +export { + sendMessageWhatsApp, + sendPollWhatsApp, + sendReactionWhatsApp, +} from "../../extensions/whatsapp/src/send.js"; +export { + createWaSocket, + formatError, + getStatusCode, + waitForWaConnection, +} from "../../extensions/whatsapp/src/session.js"; +export { createWhatsAppLoginTool } from "../../extensions/whatsapp/src/agent-tools-login.js"; diff --git a/src/plugin-sdk/zai.ts b/src/plugin-sdk/zai.ts new file mode 100644 index 00000000000..6981a0994bf --- /dev/null +++ b/src/plugin-sdk/zai.ts @@ -0,0 +1,7 @@ +// Public Z.ai helpers for provider plugins that need endpoint detection. + +export { + detectZaiEndpoint, + type ZaiDetectedEndpoint, + type ZaiEndpointId, +} from "../commands/zai-endpoint-detect.js"; diff --git a/src/security/audit-channel.runtime.ts b/src/security/audit-channel.runtime.ts index c3435fc2a64..867f0a91162 100644 --- a/src/security/audit-channel.runtime.ts +++ b/src/security/audit-channel.runtime.ts @@ -6,4 +6,4 @@ export { export { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, -} from "../plugin-sdk-internal/telegram.js"; +} from "../plugin-sdk/telegram.js"; From 70da383a613ba9f572b449e5b388009ed02425e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:18:16 -0700 Subject: [PATCH 085/128] test: fix rebase fallout --- extensions/kilocode/index.ts | 2 +- extensions/qianfan/index.ts | 2 +- extensions/synthetic/index.ts | 2 +- extensions/together/index.ts | 2 +- extensions/venice/index.ts | 2 +- extensions/vercel-ai-gateway/index.ts | 2 +- extensions/xiaomi/index.ts | 2 +- src/plugins/provider-catalog.test.ts | 30 ++++++++++++++++++++------- 8 files changed, 30 insertions(+), 14 deletions(-) diff --git a/extensions/kilocode/index.ts b/extensions/kilocode/index.ts index 33dc9718021..c423606e552 100644 --- a/extensions/kilocode/index.ts +++ b/extensions/kilocode/index.ts @@ -4,8 +4,8 @@ import { createKilocodeWrapper, isProxyReasoningUnsupported, } from "openclaw/plugin-sdk/provider-stream"; -import { applyKilocodeConfig, KILOCODE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { applyKilocodeConfig, KILOCODE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildKilocodeProviderWithDiscovery } from "./provider-catalog.js"; const PROVIDER_ID = "kilocode"; diff --git a/extensions/qianfan/index.ts b/extensions/qianfan/index.ts index e8f2f2cc59d..42b5b8a0cb7 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 { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; -import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildQianfanProvider } from "./provider-catalog.js"; const PROVIDER_ID = "qianfan"; diff --git a/extensions/synthetic/index.ts b/extensions/synthetic/index.ts index 19e7424bfb7..f538dd1fbcb 100644 --- a/extensions/synthetic/index.ts +++ b/extensions/synthetic/index.ts @@ -1,7 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; -import { applySyntheticConfig, SYNTHETIC_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { applySyntheticConfig, SYNTHETIC_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSyntheticProvider } from "./provider-catalog.js"; const PROVIDER_ID = "synthetic"; diff --git a/extensions/together/index.ts b/extensions/together/index.ts index 5f6dfb3e7c4..2ae0072ca88 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -1,7 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; -import { applyTogetherConfig, TOGETHER_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { applyTogetherConfig, TOGETHER_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildTogetherProvider } from "./provider-catalog.js"; const PROVIDER_ID = "together"; diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index d25e8ffb9b8..b67831fe7a9 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 { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; -import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildVeniceProvider } from "./provider-catalog.js"; const PROVIDER_ID = "venice"; diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index 1f126260321..433f6cee09a 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -1,7 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; -import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildVercelAiGatewayProvider } from "./provider-catalog.js"; const PROVIDER_ID = "vercel-ai-gateway"; diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index 1badf6e2d9d..2edc1b33b25 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 { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { PROVIDER_LABELS } from "openclaw/plugin-sdk/provider-usage"; -import { applyXiaomiConfig, XIAOMI_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { applyXiaomiConfig, XIAOMI_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildXiaomiProvider } from "./provider-catalog.js"; const PROVIDER_ID = "xiaomi"; diff --git a/src/plugins/provider-catalog.test.ts b/src/plugins/provider-catalog.test.ts index e150d021a7b..b8c865dec5d 100644 --- a/src/plugins/provider-catalog.test.ts +++ b/src/plugins/provider-catalog.test.ts @@ -7,6 +7,15 @@ import { } from "./provider-catalog.js"; import type { ProviderCatalogContext } from "./types.js"; +function createProviderConfig(params?: { provider?: string; baseUrl?: string }) { + return { + api: "openai-completions" as const, + provider: params?.provider ?? "test-provider", + baseUrl: params?.baseUrl ?? "https://default.example/v1", + models: [], + }; +} + function createCatalogContext(params: { config?: OpenClawConfig; apiKeys?: Record; @@ -38,7 +47,7 @@ describe("buildSingleProviderApiKeyCatalog", () => { const result = await buildSingleProviderApiKeyCatalog({ ctx: createCatalogContext({}), providerId: "test-provider", - buildProvider: () => ({ api: "openai-completions", provider: "test-provider" }), + buildProvider: () => createProviderConfig(), }); expect(result).toBeNull(); @@ -50,12 +59,14 @@ describe("buildSingleProviderApiKeyCatalog", () => { apiKeys: { "test-provider": "secret-key" }, }), providerId: "test-provider", - buildProvider: async () => ({ api: "openai-completions", provider: "test-provider" }), + buildProvider: async () => createProviderConfig(), }); expect(result).toEqual({ provider: { api: "openai-completions", + baseUrl: "https://default.example/v1", + models: [], provider: "test-provider", apiKey: "secret-key", }, @@ -71,6 +82,7 @@ describe("buildSingleProviderApiKeyCatalog", () => { providers: { "test-provider": { baseUrl: " https://override.example/v1/ ", + models: [], }, }, }, @@ -78,8 +90,7 @@ describe("buildSingleProviderApiKeyCatalog", () => { }), providerId: "test-provider", buildProvider: () => ({ - api: "openai-completions", - provider: "test-provider", + ...createProviderConfig(), baseUrl: "https://default.example/v1", }), allowExplicitBaseUrl: true, @@ -88,8 +99,9 @@ describe("buildSingleProviderApiKeyCatalog", () => { expect(result).toEqual({ provider: { api: "openai-completions", - provider: "test-provider", baseUrl: "https://override.example/v1/", + models: [], + provider: "test-provider", apiKey: "secret-key", }, }); @@ -102,8 +114,8 @@ describe("buildSingleProviderApiKeyCatalog", () => { }), providerId: "test-provider", buildProviders: async () => ({ - alpha: { api: "openai-completions", provider: "alpha" }, - beta: { api: "openai-completions", provider: "beta" }, + alpha: createProviderConfig({ provider: "alpha" }), + beta: createProviderConfig({ provider: "beta" }), }), }); @@ -111,11 +123,15 @@ describe("buildSingleProviderApiKeyCatalog", () => { providers: { alpha: { api: "openai-completions", + baseUrl: "https://default.example/v1", + models: [], provider: "alpha", apiKey: "secret-key", }, beta: { api: "openai-completions", + baseUrl: "https://default.example/v1", + models: [], provider: "beta", apiKey: "secret-key", }, From 529272d3383e91e365886dbc466b4e34b3626773 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 21:16:14 -0700 Subject: [PATCH 086/128] WhatsApp: lazy-load channel auth helpers --- extensions/whatsapp/src/channel.runtime.ts | 11 ++++++ extensions/whatsapp/src/channel.ts | 43 +++++++++++----------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/extensions/whatsapp/src/channel.runtime.ts b/extensions/whatsapp/src/channel.runtime.ts index ff67d34ee10..46dd5f987d2 100644 --- a/extensions/whatsapp/src/channel.runtime.ts +++ b/extensions/whatsapp/src/channel.runtime.ts @@ -1 +1,12 @@ +export { getActiveWebListener } from "./active-listener.js"; +export { + getWebAuthAgeMs, + logWebSelfId, + logoutWeb, + readWebSelfId, + webAuthExists, +} from "./auth-store.js"; +export { loginWeb } from "./login.js"; +export { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js"; export { whatsappSetupWizard } from "./setup-surface.js"; +export { monitorWebChannel } from "../../../src/channels/web/index.js"; diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 4701c80070b..7e4be853c23 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -223,7 +223,7 @@ export const whatsappPlugin: ChannelPlugin = { directory: { self: async ({ cfg, accountId }) => { const account = resolveWhatsAppAccount({ cfg, accountId }); - const { e164, jid } = getWhatsAppRuntime().channel.whatsapp.readWebSelfId(account.authDir); + const { e164, jid } = (await loadWhatsAppChannelRuntime()).readWebSelfId(account.authDir); const id = e164 ?? jid; if (!id) { return null; @@ -298,12 +298,9 @@ export const whatsappPlugin: ChannelPlugin = { auth: { login: async ({ cfg, accountId, runtime, verbose }) => { const resolvedAccountId = accountId?.trim() || resolveDefaultWhatsAppAccountId(cfg); - await getWhatsAppRuntime().channel.whatsapp.loginWeb( - Boolean(verbose), - undefined, - runtime, - resolvedAccountId, - ); + await ( + await loadWhatsAppChannelRuntime() + ).loginWeb(Boolean(verbose), undefined, runtime, resolvedAccountId); }, }, heartbeat: { @@ -313,14 +310,14 @@ export const whatsappPlugin: ChannelPlugin = { } const account = resolveWhatsAppAccount({ cfg, accountId }); const authExists = await ( - deps?.webAuthExists ?? getWhatsAppRuntime().channel.whatsapp.webAuthExists + deps?.webAuthExists ?? (await loadWhatsAppChannelRuntime()).webAuthExists )(account.authDir); if (!authExists) { return { ok: false, reason: "whatsapp-not-linked" }; } const listenerActive = deps?.hasActiveWebListener ? deps.hasActiveWebListener() - : Boolean(getWhatsAppRuntime().channel.whatsapp.getActiveWebListener()); + : Boolean((await loadWhatsAppChannelRuntime()).getActiveWebListener()); if (!listenerActive) { return { ok: false, reason: "whatsapp-not-running" }; } @@ -347,13 +344,13 @@ export const whatsappPlugin: ChannelPlugin = { typeof snapshot.linked === "boolean" ? snapshot.linked : authDir - ? await getWhatsAppRuntime().channel.whatsapp.webAuthExists(authDir) + ? await (await loadWhatsAppChannelRuntime()).webAuthExists(authDir) : false; const authAgeMs = - linked && authDir ? getWhatsAppRuntime().channel.whatsapp.getWebAuthAgeMs(authDir) : null; + linked && authDir ? (await loadWhatsAppChannelRuntime()).getWebAuthAgeMs(authDir) : null; const self = linked && authDir - ? getWhatsAppRuntime().channel.whatsapp.readWebSelfId(authDir) + ? (await loadWhatsAppChannelRuntime()).readWebSelfId(authDir) : { e164: null, jid: null }; return { configured: linked, @@ -371,7 +368,7 @@ export const whatsappPlugin: ChannelPlugin = { }; }, buildAccountSnapshot: async ({ account, runtime }) => { - const linked = await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir); + const linked = await (await loadWhatsAppChannelRuntime()).webAuthExists(account.authDir); return { accountId: account.accountId, name: account.name, @@ -392,20 +389,18 @@ export const whatsappPlugin: ChannelPlugin = { }, resolveAccountState: ({ configured }) => (configured ? "linked" : "not linked"), logSelfId: ({ account, runtime, includeChannelPrefix }) => { - getWhatsAppRuntime().channel.whatsapp.logWebSelfId( - account.authDir, - runtime, - includeChannelPrefix, + void loadWhatsAppChannelRuntime().then((runtimeExports) => + runtimeExports.logWebSelfId(account.authDir, runtime, includeChannelPrefix), ); }, }, gateway: { startAccount: async (ctx) => { const account = ctx.account; - const { e164, jid } = getWhatsAppRuntime().channel.whatsapp.readWebSelfId(account.authDir); + const { e164, jid } = (await loadWhatsAppChannelRuntime()).readWebSelfId(account.authDir); const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; ctx.log?.info(`[${account.accountId}] starting provider (${identity})`); - return getWhatsAppRuntime().channel.whatsapp.monitorWebChannel( + return (await loadWhatsAppChannelRuntime()).monitorWebChannel( getWhatsAppRuntime().logging.shouldLogVerbose(), undefined, true, @@ -419,16 +414,20 @@ export const whatsappPlugin: ChannelPlugin = { ); }, loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => - await getWhatsAppRuntime().channel.whatsapp.startWebLoginWithQr({ + await ( + await loadWhatsAppChannelRuntime() + ).startWebLoginWithQr({ accountId, force, timeoutMs, verbose, }), loginWithQrWait: async ({ accountId, timeoutMs }) => - await getWhatsAppRuntime().channel.whatsapp.waitForWebLogin({ accountId, timeoutMs }), + await (await loadWhatsAppChannelRuntime()).waitForWebLogin({ accountId, timeoutMs }), logoutAccount: async ({ account, runtime }) => { - const cleared = await getWhatsAppRuntime().channel.whatsapp.logoutWeb({ + const cleared = await ( + await loadWhatsAppChannelRuntime() + ).logoutWeb({ authDir: account.authDir, isLegacyAuthDir: account.isLegacyAuthDir, runtime, From 9183081bf1aaed88ffbd9f62b8fbb5ffcae00fa6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:19:37 -0700 Subject: [PATCH 087/128] refactor: move provider auth helpers into plugin layer --- .../auth-choice.apply.api-key-providers.ts | 10 +- src/commands/auth-choice.apply.oauth.ts | 2 +- .../auth-choice.apply.plugin-provider.test.ts | 2 +- .../auth-choice.apply.plugin-provider.ts | 2 +- src/commands/auth-credentials.ts | 195 +-------- src/commands/auth-profile-config.ts | 75 +--- src/commands/models/auth.ts | 2 +- src/commands/ollama-setup.ts | 2 +- src/commands/onboard-auth.config-core.ts | 2 +- src/commands/onboard-auth.config-litellm.ts | 4 +- src/commands/onboard-auth.config-shared.ts | 228 +---------- src/commands/onboard-auth.credentials.ts | 385 ++---------------- .../local/auth-choice.ts | 8 +- src/commands/self-hosted-provider-setup.ts | 2 +- src/plugin-sdk/provider-auth.ts | 3 +- src/plugin-sdk/provider-onboard.ts | 2 +- src/plugins/provider-api-key-auth.runtime.ts | 3 +- src/providers/github-copilot-auth.ts | 2 +- 18 files changed, 68 insertions(+), 861 deletions(-) diff --git a/src/commands/auth-choice.apply.api-key-providers.ts b/src/commands/auth-choice.apply.api-key-providers.ts index 3ff35a46365..0d508ff687f 100644 --- a/src/commands/auth-choice.apply.api-key-providers.ts +++ b/src/commands/auth-choice.apply.api-key-providers.ts @@ -1,14 +1,10 @@ import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../agents/auth-profiles.js"; +import { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; +import { LITELLM_DEFAULT_MODEL_REF, setLitellmApiKey } from "../plugins/provider-auth-storage.js"; import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { ensureApiKeyFromOptionEnvOrPrompt } from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { - applyAuthProfileConfig, - applyLitellmConfig, - applyLitellmProviderConfig, - LITELLM_DEFAULT_MODEL_REF, - setLitellmApiKey, -} from "./onboard-auth.js"; +import { applyLitellmConfig, applyLitellmProviderConfig } from "./onboard-auth.config-litellm.js"; import type { SecretInputMode } from "./onboard-types.js"; type ApiKeyProviderConfigApplier = ( diff --git a/src/commands/auth-choice.apply.oauth.ts b/src/commands/auth-choice.apply.oauth.ts index 0e9a5523ce0..a2a3104e447 100644 --- a/src/commands/auth-choice.apply.oauth.ts +++ b/src/commands/auth-choice.apply.oauth.ts @@ -1,8 +1,8 @@ +import { applyAuthProfileConfig, writeOAuthCredentials } from "../plugins/provider-auth-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { loginChutes } from "./chutes-oauth.js"; import { isRemoteEnvironment } from "./oauth-env.js"; import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; -import { applyAuthProfileConfig, writeOAuthCredentials } from "./onboard-auth.js"; import { openUrl } from "./onboard-helpers.js"; export async function applyAuthChoiceOAuth( diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index 27615989d1d..1e731fde48f 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -44,7 +44,7 @@ vi.mock("../agents/agent-paths.js", () => ({ })); const applyAuthProfileConfig = vi.hoisted(() => vi.fn((config) => config)); -vi.mock("./onboard-auth.js", () => ({ +vi.mock("../plugins/provider-auth-helpers.js", () => ({ applyAuthProfileConfig, })); diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index da125a4065d..afdad97ecec 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -7,11 +7,11 @@ import { import { upsertAuthProfile } from "../agents/auth-profiles.js"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { enablePluginInConfig } from "../plugins/enable.js"; +import { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; import type { ProviderAuthMethod } from "../plugins/types.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { isRemoteEnvironment } from "./oauth-env.js"; import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; -import { applyAuthProfileConfig } from "./onboard-auth.js"; import { openUrl } from "./onboard-helpers.js"; import type { OnboardOptions } from "./onboard-types.js"; import { diff --git a/src/commands/auth-credentials.ts b/src/commands/auth-credentials.ts index 4ee69149a92..94e320f48db 100644 --- a/src/commands/auth-credentials.ts +++ b/src/commands/auth-credentials.ts @@ -1,189 +1,6 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { OAuthCredentials } from "@mariozechner/pi-ai"; -import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; -import { upsertAuthProfile } from "../agents/auth-profiles.js"; -import { resolveStateDir } from "../config/paths.js"; -import { - coerceSecretRef, - DEFAULT_SECRET_PROVIDER_ALIAS, - type SecretInput, - type SecretRef, -} from "../config/types.secrets.js"; -import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; -import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; -import type { SecretInputMode } from "./onboard-types.js"; - -const ENV_REF_PATTERN = /^\$\{([A-Z][A-Z0-9_]*)\}$/; - -const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); - -export type ApiKeyStorageOptions = { - secretInputMode?: SecretInputMode; -}; - -export type WriteOAuthCredentialsOptions = { - syncSiblingAgents?: boolean; -}; - -function buildEnvSecretRef(id: string): SecretRef { - return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id }; -} - -function parseEnvSecretRef(value: string): SecretRef | null { - const match = ENV_REF_PATTERN.exec(value); - if (!match) { - return null; - } - return buildEnvSecretRef(match[1]); -} - -function resolveProviderDefaultEnvSecretRef(provider: string): SecretRef { - const envVars = PROVIDER_ENV_VARS[provider]; - const envVar = envVars?.find((candidate) => candidate.trim().length > 0); - if (!envVar) { - throw new Error( - `Provider "${provider}" does not have a default env var mapping for secret-input-mode=ref.`, - ); - } - return buildEnvSecretRef(envVar); -} - -function resolveApiKeySecretInput( - provider: string, - input: SecretInput, - options?: ApiKeyStorageOptions, -): SecretInput { - const coercedRef = coerceSecretRef(input); - if (coercedRef) { - return coercedRef; - } - const normalized = normalizeSecretInput(input); - const inlineEnvRef = parseEnvSecretRef(normalized); - if (inlineEnvRef) { - return inlineEnvRef; - } - if (options?.secretInputMode === "ref") { - return resolveProviderDefaultEnvSecretRef(provider); - } - return normalized; -} - -export function buildApiKeyCredential( - provider: string, - input: SecretInput, - metadata?: Record, - options?: ApiKeyStorageOptions, -): { - type: "api_key"; - provider: string; - key?: string; - keyRef?: SecretRef; - metadata?: Record; -} { - const secretInput = resolveApiKeySecretInput(provider, input, options); - if (typeof secretInput === "string") { - return { - type: "api_key", - provider, - key: secretInput, - ...(metadata ? { metadata } : {}), - }; - } - return { - type: "api_key", - provider, - keyRef: secretInput, - ...(metadata ? { metadata } : {}), - }; -} - -/** Resolve real path, returning null if the target doesn't exist. */ -function safeRealpathSync(dir: string): string | null { - try { - return fs.realpathSync(path.resolve(dir)); - } catch { - return null; - } -} - -function resolveSiblingAgentDirs(primaryAgentDir: string): string[] { - const normalized = path.resolve(primaryAgentDir); - const parentOfAgent = path.dirname(normalized); - const candidateAgentsRoot = path.dirname(parentOfAgent); - const looksLikeStandardLayout = - path.basename(normalized) === "agent" && path.basename(candidateAgentsRoot) === "agents"; - - const agentsRoot = looksLikeStandardLayout - ? candidateAgentsRoot - : path.join(resolveStateDir(), "agents"); - - const entries = (() => { - try { - return fs.readdirSync(agentsRoot, { withFileTypes: true }); - } catch { - return []; - } - })(); - const discovered = entries - .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) - .map((entry) => path.join(agentsRoot, entry.name, "agent")); - - const seen = new Set(); - const result: string[] = []; - for (const dir of [normalized, ...discovered]) { - const real = safeRealpathSync(dir); - if (real && !seen.has(real)) { - seen.add(real); - result.push(real); - } - } - return result; -} - -export async function writeOAuthCredentials( - provider: string, - creds: OAuthCredentials, - agentDir?: string, - options?: WriteOAuthCredentialsOptions, -): Promise { - const email = - typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default"; - const profileId = `${provider}:${email}`; - const resolvedAgentDir = path.resolve(resolveAuthAgentDir(agentDir)); - const targetAgentDirs = options?.syncSiblingAgents - ? resolveSiblingAgentDirs(resolvedAgentDir) - : [resolvedAgentDir]; - - const credential = { - type: "oauth" as const, - provider, - ...creds, - }; - - upsertAuthProfile({ - profileId, - credential, - agentDir: resolvedAgentDir, - }); - - if (options?.syncSiblingAgents) { - const primaryReal = safeRealpathSync(resolvedAgentDir); - for (const targetAgentDir of targetAgentDirs) { - const targetReal = safeRealpathSync(targetAgentDir); - if (targetReal && primaryReal && targetReal === primaryReal) { - continue; - } - try { - upsertAuthProfile({ - profileId, - credential, - agentDir: targetAgentDir, - }); - } catch { - // Best-effort: sibling sync failure must not block primary setup. - } - } - } - return profileId; -} +export { + buildApiKeyCredential, + type ApiKeyStorageOptions, + writeOAuthCredentials, + type WriteOAuthCredentialsOptions, +} from "../plugins/provider-auth-helpers.js"; diff --git a/src/commands/auth-profile-config.ts b/src/commands/auth-profile-config.ts index 90be398f5b0..c3879e01846 100644 --- a/src/commands/auth-profile-config.ts +++ b/src/commands/auth-profile-config.ts @@ -1,74 +1 @@ -import { normalizeProviderIdForAuth } from "../agents/provider-id.js"; -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 = normalizeProviderIdForAuth(params.provider); - 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]) => normalizeProviderIdForAuth(profile.provider) === 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 { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 6001ede2ea4..c3de2dd06dc 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -23,6 +23,7 @@ import { formatCliCommand } from "../../cli/command-format.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; import type { OpenClawConfig } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; +import { applyAuthProfileConfig } from "../../plugins/provider-auth-helpers.js"; import { resolvePluginProviders } from "../../plugins/providers.js"; import type { ProviderAuthMethod, @@ -34,7 +35,6 @@ import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { isRemoteEnvironment } from "../oauth-env.js"; import { createVpsAwareOAuthHandlers } from "../oauth-flow.js"; -import { applyAuthProfileConfig } from "../onboard-auth.js"; import { openUrl } from "../onboard-helpers.js"; import { applyDefaultModel, diff --git a/src/commands/ollama-setup.ts b/src/commands/ollama-setup.ts index 4557f606bb6..31499d3f0a6 100644 --- a/src/commands/ollama-setup.ts +++ b/src/commands/ollama-setup.ts @@ -8,10 +8,10 @@ import { type OllamaModelWithContext, } from "../agents/ollama-models.js"; import type { OpenClawConfig } from "../config/config.js"; +import { applyAgentDefaultModelPrimary } from "../plugins/provider-onboarding-config.js"; import type { RuntimeEnv } from "../runtime.js"; import { WizardCancelledError, type WizardPrompter } from "../wizard/prompts.js"; import { isRemoteEnvironment } from "./oauth-env.js"; -import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; import { openUrl } from "./onboard-helpers.js"; import type { OnboardMode, OnboardOptions } from "./onboard-types.js"; diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 7a78df71144..65b4fd40cf0 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -10,7 +10,7 @@ export { LITELLM_BASE_URL, LITELLM_DEFAULT_MODEL_ID, } from "./onboard-auth.config-litellm.js"; -export { applyAuthProfileConfig } from "./auth-profile-config.js"; +export { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; export { applyHuggingfaceConfig, applyHuggingfaceProviderConfig, diff --git a/src/commands/onboard-auth.config-litellm.ts b/src/commands/onboard-auth.config-litellm.ts index ec1ba251056..2dd60bab894 100644 --- a/src/commands/onboard-auth.config-litellm.ts +++ b/src/commands/onboard-auth.config-litellm.ts @@ -1,9 +1,9 @@ import type { OpenClawConfig } from "../config/config.js"; +import { LITELLM_DEFAULT_MODEL_REF } from "../plugins/provider-auth-storage.js"; import { applyAgentDefaultModelPrimary, applyProviderConfigWithDefaultModel, -} from "./onboard-auth.config-shared.js"; -import { LITELLM_DEFAULT_MODEL_REF } from "./onboard-auth.credentials.js"; +} from "../plugins/provider-onboarding-config.js"; export const LITELLM_BASE_URL = "http://localhost:4000"; export const LITELLM_DEFAULT_MODEL_ID = "claude-opus-4-6"; diff --git a/src/commands/onboard-auth.config-shared.ts b/src/commands/onboard-auth.config-shared.ts index 9e70eaac192..7c278eec644 100644 --- a/src/commands/onboard-auth.config-shared.ts +++ b/src/commands/onboard-auth.config-shared.ts @@ -1,221 +1,7 @@ -import { findNormalizedProviderKey } from "../agents/provider-id.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js"; -import type { - ModelApi, - ModelDefinitionConfig, - ModelProviderConfig, -} from "../config/types.models.js"; - -function extractAgentDefaultModelFallbacks(model: unknown): string[] | undefined { - if (!model || typeof model !== "object") { - return undefined; - } - if (!("fallbacks" in model)) { - return undefined; - } - const fallbacks = (model as { fallbacks?: unknown }).fallbacks; - return Array.isArray(fallbacks) ? fallbacks.map((v) => String(v)) : undefined; -} - -export function applyOnboardAuthAgentModelsAndProviders( - cfg: OpenClawConfig, - params: { - agentModels: Record; - providers: Record; - }, -): OpenClawConfig { - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models: params.agentModels, - }, - }, - models: { - mode: cfg.models?.mode ?? "merge", - providers: params.providers, - }, - }; -} - -export function applyAgentDefaultModelPrimary( - cfg: OpenClawConfig, - primary: string, -): OpenClawConfig { - const existingFallbacks = extractAgentDefaultModelFallbacks(cfg.agents?.defaults?.model); - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - model: { - ...(existingFallbacks ? { fallbacks: existingFallbacks } : undefined), - primary, - }, - }, - }, - }; -} - -export function applyProviderConfigWithDefaultModels( - cfg: OpenClawConfig, - params: { - agentModels: Record; - providerId: string; - api: ModelApi; - baseUrl: string; - defaultModels: ModelDefinitionConfig[]; - defaultModelId?: string; - }, -): OpenClawConfig { - const providerState = resolveProviderModelMergeState(cfg, params.providerId); - - const defaultModels = params.defaultModels; - const defaultModelId = params.defaultModelId ?? defaultModels[0]?.id; - const hasDefaultModel = defaultModelId - ? providerState.existingModels.some((model) => model.id === defaultModelId) - : true; - const mergedModels = - providerState.existingModels.length > 0 - ? hasDefaultModel || defaultModels.length === 0 - ? providerState.existingModels - : [...providerState.existingModels, ...defaultModels] - : defaultModels; - return applyProviderConfigWithMergedModels(cfg, { - agentModels: params.agentModels, - providerId: params.providerId, - providerState, - api: params.api, - baseUrl: params.baseUrl, - mergedModels, - fallbackModels: defaultModels, - }); -} - -export function applyProviderConfigWithDefaultModel( - cfg: OpenClawConfig, - params: { - agentModels: Record; - providerId: string; - api: ModelApi; - baseUrl: string; - defaultModel: ModelDefinitionConfig; - defaultModelId?: string; - }, -): OpenClawConfig { - return applyProviderConfigWithDefaultModels(cfg, { - agentModels: params.agentModels, - providerId: params.providerId, - api: params.api, - baseUrl: params.baseUrl, - defaultModels: [params.defaultModel], - defaultModelId: params.defaultModelId ?? params.defaultModel.id, - }); -} - -export function applyProviderConfigWithModelCatalog( - cfg: OpenClawConfig, - params: { - agentModels: Record; - providerId: string; - api: ModelApi; - baseUrl: string; - catalogModels: ModelDefinitionConfig[]; - }, -): OpenClawConfig { - const providerState = resolveProviderModelMergeState(cfg, params.providerId); - const catalogModels = params.catalogModels; - const mergedModels = - providerState.existingModels.length > 0 - ? [ - ...providerState.existingModels, - ...catalogModels.filter( - (model) => !providerState.existingModels.some((existing) => existing.id === model.id), - ), - ] - : catalogModels; - return applyProviderConfigWithMergedModels(cfg, { - agentModels: params.agentModels, - providerId: params.providerId, - providerState, - api: params.api, - baseUrl: params.baseUrl, - mergedModels, - fallbackModels: catalogModels, - }); -} - -type ProviderModelMergeState = { - providers: Record; - existingProvider?: ModelProviderConfig; - existingModels: ModelDefinitionConfig[]; -}; - -function resolveProviderModelMergeState( - cfg: OpenClawConfig, - providerId: string, -): ProviderModelMergeState { - const providers = { ...cfg.models?.providers } as Record; - const existingProviderKey = findNormalizedProviderKey(providers, providerId); - const existingProvider = - existingProviderKey !== undefined - ? (providers[existingProviderKey] as ModelProviderConfig | undefined) - : undefined; - const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models) - ? existingProvider.models - : []; - if (existingProviderKey && existingProviderKey !== providerId) { - delete providers[existingProviderKey]; - } - return { providers, existingProvider, existingModels }; -} - -function applyProviderConfigWithMergedModels( - cfg: OpenClawConfig, - params: { - agentModels: Record; - providerId: string; - providerState: ProviderModelMergeState; - api: ModelApi; - baseUrl: string; - mergedModels: ModelDefinitionConfig[]; - fallbackModels: ModelDefinitionConfig[]; - }, -): OpenClawConfig { - params.providerState.providers[params.providerId] = buildProviderConfig({ - existingProvider: params.providerState.existingProvider, - api: params.api, - baseUrl: params.baseUrl, - mergedModels: params.mergedModels, - fallbackModels: params.fallbackModels, - }); - return applyOnboardAuthAgentModelsAndProviders(cfg, { - agentModels: params.agentModels, - providers: params.providerState.providers, - }); -} - -function buildProviderConfig(params: { - existingProvider: ModelProviderConfig | undefined; - api: ModelApi; - baseUrl: string; - mergedModels: ModelDefinitionConfig[]; - fallbackModels: ModelDefinitionConfig[]; -}): ModelProviderConfig { - const { apiKey: existingApiKey, ...existingProviderRest } = (params.existingProvider ?? {}) as { - apiKey?: string; - }; - const normalizedApiKey = typeof existingApiKey === "string" ? existingApiKey.trim() : undefined; - - return { - ...existingProviderRest, - baseUrl: params.baseUrl, - api: params.api, - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - models: params.mergedModels.length > 0 ? params.mergedModels : params.fallbackModels, - }; -} +export { + applyAgentDefaultModelPrimary, + applyOnboardAuthAgentModelsAndProviders, + applyProviderConfigWithDefaultModel, + applyProviderConfigWithDefaultModels, + applyProviderConfigWithModelCatalog, +} from "../plugins/provider-onboarding-config.js"; diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index 4377a8b4de3..578ad17859d 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -1,358 +1,43 @@ -import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; -import { upsertAuthProfile } from "../agents/auth-profiles.js"; -import type { SecretInput } from "../config/types.secrets.js"; -import { KILOCODE_DEFAULT_MODEL_REF } from "../providers/kilocode-shared.js"; -import { +export { buildApiKeyCredential, type ApiKeyStorageOptions, + HUGGINGFACE_DEFAULT_MODEL_REF, + KILOCODE_DEFAULT_MODEL_REF, + LITELLM_DEFAULT_MODEL_REF, + OPENROUTER_DEFAULT_MODEL_REF, + setAnthropicApiKey, + setByteplusApiKey, + setCloudflareAiGatewayConfig, + setGeminiApiKey, + setHuggingfaceApiKey, + setKilocodeApiKey, + setKimiCodingApiKey, + setLitellmApiKey, + setMinimaxApiKey, + setMistralApiKey, + setModelStudioApiKey, + setMoonshotApiKey, + setOpenaiApiKey, + setOpencodeGoApiKey, + setOpencodeZenApiKey, + setOpenrouterApiKey, + setQianfanApiKey, + setSyntheticApiKey, + setTogetherApiKey, + setVeniceApiKey, + setVercelAiGatewayApiKey, + setVolcengineApiKey, + setXaiApiKey, + setXiaomiApiKey, + setZaiApiKey, + TOGETHER_DEFAULT_MODEL_REF, + VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, writeOAuthCredentials, type WriteOAuthCredentialsOptions, -} from "./auth-credentials.js"; + XIAOMI_DEFAULT_MODEL_REF, + ZAI_DEFAULT_MODEL_REF, +} from "../plugins/provider-auth-storage.js"; export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js"; export { MISTRAL_DEFAULT_MODEL_REF } from "../../extensions/mistral/onboard.js"; export { MODELSTUDIO_DEFAULT_MODEL_REF } from "../../extensions/modelstudio/onboard.js"; export { XAI_DEFAULT_MODEL_REF } from "../../extensions/xai/onboard.js"; -export { KILOCODE_DEFAULT_MODEL_REF }; -export { - buildApiKeyCredential, - type ApiKeyStorageOptions, - writeOAuthCredentials, - type WriteOAuthCredentialsOptions, -}; - -const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); - -export async function setAnthropicApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - // Write to resolved agent dir so gateway finds credentials on startup. - upsertAuthProfile({ - profileId: "anthropic:default", - credential: buildApiKeyCredential("anthropic", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setOpenaiApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - upsertAuthProfile({ - profileId: "openai:default", - credential: buildApiKeyCredential("openai", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setGeminiApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - // Write to resolved agent dir so gateway finds credentials on startup. - upsertAuthProfile({ - profileId: "google:default", - credential: buildApiKeyCredential("google", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setMinimaxApiKey( - key: SecretInput, - agentDir?: string, - profileId: string = "minimax:default", - options?: ApiKeyStorageOptions, -) { - const provider = profileId.split(":")[0] ?? "minimax"; - // Write to resolved agent dir so gateway finds credentials on startup. - upsertAuthProfile({ - profileId, - credential: buildApiKeyCredential(provider, key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setMoonshotApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - // Write to resolved agent dir so gateway finds credentials on startup. - upsertAuthProfile({ - profileId: "moonshot:default", - credential: buildApiKeyCredential("moonshot", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setKimiCodingApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - // Write to resolved agent dir so gateway finds credentials on startup. - upsertAuthProfile({ - profileId: "kimi:default", - credential: buildApiKeyCredential("kimi", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setVolcengineApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - upsertAuthProfile({ - profileId: "volcengine:default", - credential: buildApiKeyCredential("volcengine", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setByteplusApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - upsertAuthProfile({ - profileId: "byteplus:default", - credential: buildApiKeyCredential("byteplus", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setSyntheticApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - // Write to resolved agent dir so gateway finds credentials on startup. - upsertAuthProfile({ - profileId: "synthetic:default", - credential: buildApiKeyCredential("synthetic", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setVeniceApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - // Write to resolved agent dir so gateway finds credentials on startup. - upsertAuthProfile({ - profileId: "venice:default", - credential: buildApiKeyCredential("venice", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export const ZAI_DEFAULT_MODEL_REF = "zai/glm-5"; -export const XIAOMI_DEFAULT_MODEL_REF = "xiaomi/mimo-v2-flash"; -export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto"; -export const HUGGINGFACE_DEFAULT_MODEL_REF = "huggingface/deepseek-ai/DeepSeek-R1"; -export const TOGETHER_DEFAULT_MODEL_REF = "together/moonshotai/Kimi-K2.5"; -export const LITELLM_DEFAULT_MODEL_REF = "litellm/claude-opus-4-6"; -export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.6"; - -export async function setZaiApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - // Write to resolved agent dir so gateway finds credentials on startup. - upsertAuthProfile({ - profileId: "zai:default", - credential: buildApiKeyCredential("zai", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setXiaomiApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - upsertAuthProfile({ - profileId: "xiaomi:default", - credential: buildApiKeyCredential("xiaomi", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setOpenrouterApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - // Never persist the literal "undefined" (e.g. when prompt returns undefined and caller used String(key)). - const safeKey = typeof key === "string" && key === "undefined" ? "" : key; - upsertAuthProfile({ - profileId: "openrouter:default", - credential: buildApiKeyCredential("openrouter", safeKey, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setCloudflareAiGatewayConfig( - accountId: string, - gatewayId: string, - apiKey: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - const normalizedAccountId = accountId.trim(); - const normalizedGatewayId = gatewayId.trim(); - upsertAuthProfile({ - profileId: "cloudflare-ai-gateway:default", - credential: buildApiKeyCredential( - "cloudflare-ai-gateway", - apiKey, - { - accountId: normalizedAccountId, - gatewayId: normalizedGatewayId, - }, - options, - ), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setLitellmApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - upsertAuthProfile({ - profileId: "litellm:default", - credential: buildApiKeyCredential("litellm", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setVercelAiGatewayApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - upsertAuthProfile({ - profileId: "vercel-ai-gateway:default", - credential: buildApiKeyCredential("vercel-ai-gateway", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setOpencodeZenApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - await setSharedOpencodeApiKey(key, agentDir, options); -} - -export async function setOpencodeGoApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - await setSharedOpencodeApiKey(key, agentDir, options); -} - -async function setSharedOpencodeApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - const resolvedAgentDir = resolveAuthAgentDir(agentDir); - for (const provider of ["opencode", "opencode-go"] as const) { - upsertAuthProfile({ - profileId: `${provider}:default`, - credential: buildApiKeyCredential(provider, key, undefined, options), - agentDir: resolvedAgentDir, - }); - } -} - -export async function setTogetherApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - upsertAuthProfile({ - profileId: "together:default", - credential: buildApiKeyCredential("together", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setHuggingfaceApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - upsertAuthProfile({ - profileId: "huggingface:default", - credential: buildApiKeyCredential("huggingface", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export function setQianfanApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - upsertAuthProfile({ - profileId: "qianfan:default", - credential: buildApiKeyCredential("qianfan", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export function setModelStudioApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - upsertAuthProfile({ - profileId: "modelstudio:default", - credential: buildApiKeyCredential("modelstudio", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export function setXaiApiKey(key: SecretInput, agentDir?: string, options?: ApiKeyStorageOptions) { - upsertAuthProfile({ - profileId: "xai:default", - credential: buildApiKeyCredential("xai", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setMistralApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - upsertAuthProfile({ - profileId: "mistral:default", - credential: buildApiKeyCredential("mistral", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} - -export async function setKilocodeApiKey( - key: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, -) { - upsertAuthProfile({ - profileId: "kilocode:default", - credential: buildApiKeyCredential("kilocode", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); -} diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index c52be44afda..85322122e1f 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -1,15 +1,13 @@ import type { ApiKeyCredential } from "../../../agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { SecretInput } from "../../../config/types.secrets.js"; +import { applyAuthProfileConfig } from "../../../plugins/provider-auth-helpers.js"; +import { setCloudflareAiGatewayConfig } from "../../../plugins/provider-auth-storage.js"; import type { RuntimeEnv } from "../../../runtime.js"; import { resolveDefaultSecretProviderAlias } from "../../../secrets/ref-contract.js"; import { normalizeSecretInputModeInput } from "../../auth-choice.apply-helpers.js"; import { normalizeApiKeyTokenProviderAuthChoice } from "../../auth-choice.apply.api-providers.js"; -import { - applyAuthProfileConfig, - applyCloudflareAiGatewayConfig, - setCloudflareAiGatewayConfig, -} from "../../onboard-auth.js"; +import { applyCloudflareAiGatewayConfig } from "../../onboard-auth.config-gateways.js"; import { applyCustomApiConfig, CustomApiError, diff --git a/src/commands/self-hosted-provider-setup.ts b/src/commands/self-hosted-provider-setup.ts index ec2d8c683e3..e7851fdf550 100644 --- a/src/commands/self-hosted-provider-setup.ts +++ b/src/commands/self-hosted-provider-setup.ts @@ -6,6 +6,7 @@ import { SELF_HOSTED_DEFAULT_MAX_TOKENS, } from "../agents/self-hosted-provider-defaults.js"; import type { OpenClawConfig } from "../config/config.js"; +import { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; import type { ProviderDiscoveryContext, ProviderAuthResult, @@ -13,7 +14,6 @@ import type { ProviderNonInteractiveApiKeyResult, } from "../plugins/types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -import { applyAuthProfileConfig } from "./auth-profile-config.js"; export { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 40669e51d97..bb0c307c294 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -29,8 +29,7 @@ export { resolveSecretInputModeForEnvSelection, } from "../commands/auth-choice.apply-helpers.js"; export { buildTokenProfileId, validateAnthropicSetupToken } from "../commands/auth-token.js"; -export { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js"; -export { applyAuthProfileConfig } from "../commands/onboard-auth.js"; +export { applyAuthProfileConfig, buildApiKeyCredential } from "../plugins/provider-auth-helpers.js"; export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; export { loginOpenAICodexOAuth } from "../commands/openai-codex-oauth.js"; export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts index b2175f092fe..89b219bedbc 100644 --- a/src/plugin-sdk/provider-onboard.ts +++ b/src/plugin-sdk/provider-onboard.ts @@ -12,5 +12,5 @@ export { applyProviderConfigWithDefaultModel, applyProviderConfigWithDefaultModels, applyProviderConfigWithModelCatalog, -} from "../commands/onboard-auth.config-shared.js"; +} from "../plugins/provider-onboarding-config.js"; export { ensureModelAllowlistEntry } from "../commands/model-allowlist.js"; diff --git a/src/plugins/provider-api-key-auth.runtime.ts b/src/plugins/provider-api-key-auth.runtime.ts index 010e2b3e16e..dade8720478 100644 --- a/src/plugins/provider-api-key-auth.runtime.ts +++ b/src/plugins/provider-api-key-auth.runtime.ts @@ -1,8 +1,7 @@ import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js"; import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js"; -import { buildApiKeyCredential } from "../commands/auth-credentials.js"; import { applyPrimaryModel } from "../commands/model-picker.js"; -import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; +import { applyAuthProfileConfig, buildApiKeyCredential } from "./provider-auth-helpers.js"; export { applyAuthProfileConfig, diff --git a/src/providers/github-copilot-auth.ts b/src/providers/github-copilot-auth.ts index d4ffb926a5f..efc3cb8dbb5 100644 --- a/src/providers/github-copilot-auth.ts +++ b/src/providers/github-copilot-auth.ts @@ -1,8 +1,8 @@ import { intro, note, outro, spinner } from "@clack/prompts"; import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js"; import { updateConfig } from "../commands/models/shared.js"; -import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; import { logConfigUpdated } from "../config/logging.js"; +import { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; import type { RuntimeEnv } from "../runtime.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; From 6d6825ea182a65425f2a6277ec644228843df49f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:21:11 -0700 Subject: [PATCH 088/128] refactor: add shared provider auth modules --- src/plugins/provider-auth-helpers.ts | 262 ++++++++++++++++ src/plugins/provider-auth-storage.ts | 345 ++++++++++++++++++++++ src/plugins/provider-onboarding-config.ts | 221 ++++++++++++++ 3 files changed, 828 insertions(+) create mode 100644 src/plugins/provider-auth-helpers.ts create mode 100644 src/plugins/provider-auth-storage.ts create mode 100644 src/plugins/provider-onboarding-config.ts diff --git a/src/plugins/provider-auth-helpers.ts b/src/plugins/provider-auth-helpers.ts new file mode 100644 index 00000000000..72075dffc00 --- /dev/null +++ b/src/plugins/provider-auth-helpers.ts @@ -0,0 +1,262 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OAuthCredentials } from "@mariozechner/pi-ai"; +import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +import { upsertAuthProfile } from "../agents/auth-profiles.js"; +import { normalizeProviderIdForAuth } from "../agents/provider-id.js"; +import type { SecretInputMode } from "../commands/onboard-types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import { + coerceSecretRef, + DEFAULT_SECRET_PROVIDER_ALIAS, + type SecretInput, + type SecretRef, +} from "../config/types.secrets.js"; +import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; +import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; + +const ENV_REF_PATTERN = /^\$\{([A-Z][A-Z0-9_]*)\}$/; + +const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); + +export type ApiKeyStorageOptions = { + secretInputMode?: SecretInputMode; +}; + +export type WriteOAuthCredentialsOptions = { + syncSiblingAgents?: boolean; +}; + +function buildEnvSecretRef(id: string): SecretRef { + return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id }; +} + +function parseEnvSecretRef(value: string): SecretRef | null { + const match = ENV_REF_PATTERN.exec(value); + if (!match) { + return null; + } + return buildEnvSecretRef(match[1]); +} + +function resolveProviderDefaultEnvSecretRef(provider: string): SecretRef { + const envVars = PROVIDER_ENV_VARS[provider]; + const envVar = envVars?.find((candidate) => candidate.trim().length > 0); + if (!envVar) { + throw new Error( + `Provider "${provider}" does not have a default env var mapping for secret-input-mode=ref.`, + ); + } + return buildEnvSecretRef(envVar); +} + +function resolveApiKeySecretInput( + provider: string, + input: SecretInput, + options?: ApiKeyStorageOptions, +): SecretInput { + const coercedRef = coerceSecretRef(input); + if (coercedRef) { + return coercedRef; + } + const normalized = normalizeSecretInput(input); + const inlineEnvRef = parseEnvSecretRef(normalized); + if (inlineEnvRef) { + return inlineEnvRef; + } + if (options?.secretInputMode === "ref") { + return resolveProviderDefaultEnvSecretRef(provider); + } + return normalized; +} + +export function buildApiKeyCredential( + provider: string, + input: SecretInput, + metadata?: Record, + options?: ApiKeyStorageOptions, +): { + type: "api_key"; + provider: string; + key?: string; + keyRef?: SecretRef; + metadata?: Record; +} { + const secretInput = resolveApiKeySecretInput(provider, input, options); + if (typeof secretInput === "string") { + return { + type: "api_key", + provider, + key: secretInput, + ...(metadata ? { metadata } : {}), + }; + } + return { + type: "api_key", + provider, + keyRef: secretInput, + ...(metadata ? { metadata } : {}), + }; +} + +export function applyAuthProfileConfig( + cfg: OpenClawConfig, + params: { + profileId: string; + provider: string; + mode: "api_key" | "oauth" | "token"; + email?: string; + preferProfileFirst?: boolean; + }, +): OpenClawConfig { + const normalizedProvider = normalizeProviderIdForAuth(params.provider); + 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]) => normalizeProviderIdForAuth(profile.provider) === 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, 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 } : {}), + }, + }; +} + +/** Resolve real path, returning null if the target doesn't exist. */ +function safeRealpathSync(dir: string): string | null { + try { + return fs.realpathSync(path.resolve(dir)); + } catch { + return null; + } +} + +function resolveSiblingAgentDirs(primaryAgentDir: string): string[] { + const normalized = path.resolve(primaryAgentDir); + const parentOfAgent = path.dirname(normalized); + const candidateAgentsRoot = path.dirname(parentOfAgent); + const looksLikeStandardLayout = + path.basename(normalized) === "agent" && path.basename(candidateAgentsRoot) === "agents"; + + const agentsRoot = looksLikeStandardLayout + ? candidateAgentsRoot + : path.join(resolveStateDir(), "agents"); + + const entries = (() => { + try { + return fs.readdirSync(agentsRoot, { withFileTypes: true }); + } catch { + return []; + } + })(); + const discovered = entries + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => path.join(agentsRoot, entry.name, "agent")); + + const seen = new Set(); + const result: string[] = []; + for (const dir of [normalized, ...discovered]) { + const real = safeRealpathSync(dir); + if (real && !seen.has(real)) { + seen.add(real); + result.push(real); + } + } + return result; +} + +export async function writeOAuthCredentials( + provider: string, + creds: OAuthCredentials, + agentDir?: string, + options?: WriteOAuthCredentialsOptions, +): Promise { + const email = + typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default"; + const profileId = `${provider}:${email}`; + const resolvedAgentDir = path.resolve(resolveAuthAgentDir(agentDir)); + const targetAgentDirs = options?.syncSiblingAgents + ? resolveSiblingAgentDirs(resolvedAgentDir) + : [resolvedAgentDir]; + + const credential = { + type: "oauth" as const, + provider, + ...creds, + }; + + upsertAuthProfile({ + profileId, + credential, + agentDir: resolvedAgentDir, + }); + + if (options?.syncSiblingAgents) { + const primaryReal = safeRealpathSync(resolvedAgentDir); + for (const targetAgentDir of targetAgentDirs) { + const targetReal = safeRealpathSync(targetAgentDir); + if (targetReal && primaryReal && targetReal === primaryReal) { + continue; + } + try { + upsertAuthProfile({ + profileId, + credential, + agentDir: targetAgentDir, + }); + } catch { + // Best-effort: sibling sync failure must not block primary setup. + } + } + } + return profileId; +} diff --git a/src/plugins/provider-auth-storage.ts b/src/plugins/provider-auth-storage.ts new file mode 100644 index 00000000000..d8e15115902 --- /dev/null +++ b/src/plugins/provider-auth-storage.ts @@ -0,0 +1,345 @@ +import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +import { upsertAuthProfile } from "../agents/auth-profiles.js"; +import type { SecretInput } from "../config/types.secrets.js"; +import { KILOCODE_DEFAULT_MODEL_REF } from "../providers/kilocode-shared.js"; +import { + buildApiKeyCredential, + type ApiKeyStorageOptions, + writeOAuthCredentials, + type WriteOAuthCredentialsOptions, +} from "./provider-auth-helpers.js"; + +const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); + +export { KILOCODE_DEFAULT_MODEL_REF }; +export { + buildApiKeyCredential, + type ApiKeyStorageOptions, + writeOAuthCredentials, + type WriteOAuthCredentialsOptions, +}; + +export async function setAnthropicApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "anthropic:default", + credential: buildApiKeyCredential("anthropic", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setOpenaiApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "openai:default", + credential: buildApiKeyCredential("openai", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setGeminiApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "google:default", + credential: buildApiKeyCredential("google", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setMinimaxApiKey( + key: SecretInput, + agentDir?: string, + profileId: string = "minimax:default", + options?: ApiKeyStorageOptions, +) { + const provider = profileId.split(":")[0] ?? "minimax"; + upsertAuthProfile({ + profileId, + credential: buildApiKeyCredential(provider, key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setMoonshotApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "moonshot:default", + credential: buildApiKeyCredential("moonshot", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setKimiCodingApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "kimi:default", + credential: buildApiKeyCredential("kimi", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setVolcengineApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "volcengine:default", + credential: buildApiKeyCredential("volcengine", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setByteplusApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "byteplus:default", + credential: buildApiKeyCredential("byteplus", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setSyntheticApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "synthetic:default", + credential: buildApiKeyCredential("synthetic", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setVeniceApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "venice:default", + credential: buildApiKeyCredential("venice", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export const ZAI_DEFAULT_MODEL_REF = "zai/glm-5"; +export const XIAOMI_DEFAULT_MODEL_REF = "xiaomi/mimo-v2-flash"; +export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto"; +export const HUGGINGFACE_DEFAULT_MODEL_REF = "huggingface/deepseek-ai/DeepSeek-R1"; +export const TOGETHER_DEFAULT_MODEL_REF = "together/moonshotai/Kimi-K2.5"; +export const LITELLM_DEFAULT_MODEL_REF = "litellm/claude-opus-4-6"; +export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.6"; + +export async function setZaiApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "zai:default", + credential: buildApiKeyCredential("zai", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setXiaomiApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "xiaomi:default", + credential: buildApiKeyCredential("xiaomi", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setOpenrouterApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + const safeKey = typeof key === "string" && key === "undefined" ? "" : key; + upsertAuthProfile({ + profileId: "openrouter:default", + credential: buildApiKeyCredential("openrouter", safeKey, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setCloudflareAiGatewayConfig( + accountId: string, + gatewayId: string, + apiKey: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + const normalizedAccountId = accountId.trim(); + const normalizedGatewayId = gatewayId.trim(); + upsertAuthProfile({ + profileId: "cloudflare-ai-gateway:default", + credential: buildApiKeyCredential( + "cloudflare-ai-gateway", + apiKey, + { + accountId: normalizedAccountId, + gatewayId: normalizedGatewayId, + }, + options, + ), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setLitellmApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "litellm:default", + credential: buildApiKeyCredential("litellm", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setVercelAiGatewayApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "vercel-ai-gateway:default", + credential: buildApiKeyCredential("vercel-ai-gateway", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setOpencodeZenApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + await setSharedOpencodeApiKey(key, agentDir, options); +} + +export async function setOpencodeGoApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + await setSharedOpencodeApiKey(key, agentDir, options); +} + +async function setSharedOpencodeApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + for (const provider of ["opencode", "opencode-go"] as const) { + upsertAuthProfile({ + profileId: `${provider}:default`, + credential: buildApiKeyCredential(provider, key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); + } +} + +export async function setTogetherApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "together:default", + credential: buildApiKeyCredential("together", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setHuggingfaceApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "huggingface:default", + credential: buildApiKeyCredential("huggingface", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export function setQianfanApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "qianfan:default", + credential: buildApiKeyCredential("qianfan", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export function setModelStudioApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "modelstudio:default", + credential: buildApiKeyCredential("modelstudio", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export function setXaiApiKey(key: SecretInput, agentDir?: string, options?: ApiKeyStorageOptions) { + upsertAuthProfile({ + profileId: "xai:default", + credential: buildApiKeyCredential("xai", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setMistralApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "mistral:default", + credential: buildApiKeyCredential("mistral", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export async function setKilocodeApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "kilocode:default", + credential: buildApiKeyCredential("kilocode", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} diff --git a/src/plugins/provider-onboarding-config.ts b/src/plugins/provider-onboarding-config.ts new file mode 100644 index 00000000000..9e70eaac192 --- /dev/null +++ b/src/plugins/provider-onboarding-config.ts @@ -0,0 +1,221 @@ +import { findNormalizedProviderKey } from "../agents/provider-id.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js"; +import type { + ModelApi, + ModelDefinitionConfig, + ModelProviderConfig, +} from "../config/types.models.js"; + +function extractAgentDefaultModelFallbacks(model: unknown): string[] | undefined { + if (!model || typeof model !== "object") { + return undefined; + } + if (!("fallbacks" in model)) { + return undefined; + } + const fallbacks = (model as { fallbacks?: unknown }).fallbacks; + return Array.isArray(fallbacks) ? fallbacks.map((v) => String(v)) : undefined; +} + +export function applyOnboardAuthAgentModelsAndProviders( + cfg: OpenClawConfig, + params: { + agentModels: Record; + providers: Record; + }, +): OpenClawConfig { + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models: params.agentModels, + }, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers: params.providers, + }, + }; +} + +export function applyAgentDefaultModelPrimary( + cfg: OpenClawConfig, + primary: string, +): OpenClawConfig { + const existingFallbacks = extractAgentDefaultModelFallbacks(cfg.agents?.defaults?.model); + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { + ...(existingFallbacks ? { fallbacks: existingFallbacks } : undefined), + primary, + }, + }, + }, + }; +} + +export function applyProviderConfigWithDefaultModels( + cfg: OpenClawConfig, + params: { + agentModels: Record; + providerId: string; + api: ModelApi; + baseUrl: string; + defaultModels: ModelDefinitionConfig[]; + defaultModelId?: string; + }, +): OpenClawConfig { + const providerState = resolveProviderModelMergeState(cfg, params.providerId); + + const defaultModels = params.defaultModels; + const defaultModelId = params.defaultModelId ?? defaultModels[0]?.id; + const hasDefaultModel = defaultModelId + ? providerState.existingModels.some((model) => model.id === defaultModelId) + : true; + const mergedModels = + providerState.existingModels.length > 0 + ? hasDefaultModel || defaultModels.length === 0 + ? providerState.existingModels + : [...providerState.existingModels, ...defaultModels] + : defaultModels; + return applyProviderConfigWithMergedModels(cfg, { + agentModels: params.agentModels, + providerId: params.providerId, + providerState, + api: params.api, + baseUrl: params.baseUrl, + mergedModels, + fallbackModels: defaultModels, + }); +} + +export function applyProviderConfigWithDefaultModel( + cfg: OpenClawConfig, + params: { + agentModels: Record; + providerId: string; + api: ModelApi; + baseUrl: string; + defaultModel: ModelDefinitionConfig; + defaultModelId?: string; + }, +): OpenClawConfig { + return applyProviderConfigWithDefaultModels(cfg, { + agentModels: params.agentModels, + providerId: params.providerId, + api: params.api, + baseUrl: params.baseUrl, + defaultModels: [params.defaultModel], + defaultModelId: params.defaultModelId ?? params.defaultModel.id, + }); +} + +export function applyProviderConfigWithModelCatalog( + cfg: OpenClawConfig, + params: { + agentModels: Record; + providerId: string; + api: ModelApi; + baseUrl: string; + catalogModels: ModelDefinitionConfig[]; + }, +): OpenClawConfig { + const providerState = resolveProviderModelMergeState(cfg, params.providerId); + const catalogModels = params.catalogModels; + const mergedModels = + providerState.existingModels.length > 0 + ? [ + ...providerState.existingModels, + ...catalogModels.filter( + (model) => !providerState.existingModels.some((existing) => existing.id === model.id), + ), + ] + : catalogModels; + return applyProviderConfigWithMergedModels(cfg, { + agentModels: params.agentModels, + providerId: params.providerId, + providerState, + api: params.api, + baseUrl: params.baseUrl, + mergedModels, + fallbackModels: catalogModels, + }); +} + +type ProviderModelMergeState = { + providers: Record; + existingProvider?: ModelProviderConfig; + existingModels: ModelDefinitionConfig[]; +}; + +function resolveProviderModelMergeState( + cfg: OpenClawConfig, + providerId: string, +): ProviderModelMergeState { + const providers = { ...cfg.models?.providers } as Record; + const existingProviderKey = findNormalizedProviderKey(providers, providerId); + const existingProvider = + existingProviderKey !== undefined + ? (providers[existingProviderKey] as ModelProviderConfig | undefined) + : undefined; + const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models) + ? existingProvider.models + : []; + if (existingProviderKey && existingProviderKey !== providerId) { + delete providers[existingProviderKey]; + } + return { providers, existingProvider, existingModels }; +} + +function applyProviderConfigWithMergedModels( + cfg: OpenClawConfig, + params: { + agentModels: Record; + providerId: string; + providerState: ProviderModelMergeState; + api: ModelApi; + baseUrl: string; + mergedModels: ModelDefinitionConfig[]; + fallbackModels: ModelDefinitionConfig[]; + }, +): OpenClawConfig { + params.providerState.providers[params.providerId] = buildProviderConfig({ + existingProvider: params.providerState.existingProvider, + api: params.api, + baseUrl: params.baseUrl, + mergedModels: params.mergedModels, + fallbackModels: params.fallbackModels, + }); + return applyOnboardAuthAgentModelsAndProviders(cfg, { + agentModels: params.agentModels, + providers: params.providerState.providers, + }); +} + +function buildProviderConfig(params: { + existingProvider: ModelProviderConfig | undefined; + api: ModelApi; + baseUrl: string; + mergedModels: ModelDefinitionConfig[]; + fallbackModels: ModelDefinitionConfig[]; +}): ModelProviderConfig { + const { apiKey: existingApiKey, ...existingProviderRest } = (params.existingProvider ?? {}) as { + apiKey?: string; + }; + const normalizedApiKey = typeof existingApiKey === "string" ? existingApiKey.trim() : undefined; + + return { + ...existingProviderRest, + baseUrl: params.baseUrl, + api: params.api, + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + models: params.mergedModels.length > 0 ? params.mergedModels : params.fallbackModels, + }; +} From 4bba2888e7c1a1095c9f3839df6ea5f0a4644669 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:30:41 -0700 Subject: [PATCH 089/128] feat(plugins): add web search runtime capability --- docs/tools/plugin.md | 26 +++ extensions/test-utils/plugin-runtime-mock.ts | 4 + src/agents/tools/web-search.ts | 149 ++------------ .../tools/web-tools.enabled-defaults.test.ts | 14 ++ src/plugins/runtime/index.test.ts | 26 ++- src/plugins/runtime/index.ts | 5 + src/plugins/runtime/types-core.ts | 4 + src/plugins/web-search-providers.test.ts | 45 +++- src/plugins/web-search-providers.ts | 61 ++++-- src/web-search/runtime.test.ts | 46 +++++ src/web-search/runtime.ts | 194 ++++++++++++++++++ 11 files changed, 409 insertions(+), 165 deletions(-) create mode 100644 src/web-search/runtime.test.ts create mode 100644 src/web-search/runtime.ts diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 0e9e831023c..8ab2ba87e1f 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -782,6 +782,32 @@ Notes: - Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input). - `api.runtime.stt.transcribeAudioFile(...)` remains as a compatibility alias. +For web search, plugins can consume the shared runtime helper instead of +reaching into the agent tool wiring: + +```ts +const providers = api.runtime.webSearch.listProviders({ + config: api.config, +}); + +const result = await api.runtime.webSearch.search({ + config: api.config, + args: { + query: "OpenClaw plugin runtime helpers", + count: 5, + }, +}); +``` + +Plugins can also register web-search providers via +`api.registerWebSearchProvider(...)`. + +Notes: + +- Keep provider selection, credential resolution, and shared request semantics in core. +- Use web-search providers for vendor-specific search transports. +- `api.runtime.webSearch.*` is the preferred shared surface for feature/channel plugins that need search behavior without depending on the agent tool wrapper. + ## Gateway HTTP routes Plugins can expose HTTP endpoints with `api.registerHttpRoute(...)`. diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/extensions/test-utils/plugin-runtime-mock.ts index a5003620a59..c9f2c44cf10 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/extensions/test-utils/plugin-runtime-mock.ts @@ -115,6 +115,10 @@ export function createPluginRuntimeMock(overrides: DeepPartial = transcribeAudioFile: vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["transcribeAudioFile"], }, + webSearch: { + listProviders: vi.fn() as unknown as PluginRuntime["webSearch"]["listProviders"], + search: vi.fn() as unknown as PluginRuntime["webSearch"]["search"], + }, stt: { transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"], }, diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 869da014d45..62993704377 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -1,148 +1,29 @@ import type { OpenClawConfig } from "../../config/config.js"; -import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; -import { logVerbose } from "../../globals.js"; -import { resolvePluginWebSearchProviders } from "../../plugins/web-search-providers.js"; import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js"; -import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; +import { __testing as runtimeTesting } from "../../web-search/runtime.js"; import type { AnyAgentTool } from "./common.js"; -import { jsonResult } from "./common.js"; -import { __testing as coreTesting } from "./web-search-core.js"; - -type WebSearchConfig = NonNullable["web"] extends infer Web - ? Web extends { search?: infer Search } - ? Search - : undefined - : undefined; - -function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { - const search = cfg?.tools?.web?.search; - if (!search || typeof search !== "object") { - return undefined; - } - return search as WebSearchConfig; -} - -function resolveSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: boolean }): boolean { - if (typeof params.search?.enabled === "boolean") { - return params.search.enabled; - } - if (params.sandboxed) { - return true; - } - return true; -} - -function readProviderEnvValue(envVars: string[]): string | undefined { - for (const envVar of envVars) { - const value = normalizeSecretInput(process.env[envVar]); - if (value) { - return value; - } - } - return undefined; -} - -function hasProviderCredential(providerId: string, search: WebSearchConfig | undefined): boolean { - const providers = resolvePluginWebSearchProviders({ - bundledAllowlistCompat: true, - }); - const provider = providers.find((entry) => entry.id === providerId); - if (!provider) { - return false; - } - const rawValue = provider.getCredentialValue(search as Record | undefined); - const fromConfig = normalizeSecretInput( - normalizeResolvedSecretInputString({ - value: rawValue, - path: - providerId === "brave" - ? "tools.web.search.apiKey" - : `tools.web.search.${providerId}.apiKey`, - }), - ); - return Boolean(fromConfig || readProviderEnvValue(provider.envVars)); -} - -function resolveSearchProvider(search?: WebSearchConfig): string { - const providers = resolvePluginWebSearchProviders({ - bundledAllowlistCompat: true, - }); - const raw = - search && "provider" in search && typeof search.provider === "string" - ? search.provider.trim().toLowerCase() - : ""; - - if (raw) { - const explicit = providers.find((provider) => provider.id === raw); - if (explicit) { - return explicit.id; - } - } - - if (!raw) { - for (const provider of providers) { - if (!hasProviderCredential(provider.id, search)) { - continue; - } - logVerbose( - `web_search: no provider configured, auto-detected "${provider.id}" from available API keys`, - ); - return provider.id; - } - } - - return providers[0]?.id ?? "brave"; -} +import { + __testing as coreTesting, + createWebSearchTool as createWebSearchToolCore, +} from "./web-search-core.js"; export function createWebSearchTool(options?: { config?: OpenClawConfig; sandboxed?: boolean; runtimeWebSearch?: RuntimeWebSearchMetadata; }): AnyAgentTool | null { - const search = resolveSearchConfig(options?.config); - if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) { - return null; - } - - const providers = resolvePluginWebSearchProviders({ - config: options?.config, - bundledAllowlistCompat: true, - }); - if (providers.length === 0) { - return null; - } - - const providerId = - options?.runtimeWebSearch?.selectedProvider ?? - options?.runtimeWebSearch?.providerConfigured ?? - resolveSearchProvider(search); - const provider = - providers.find((entry) => entry.id === providerId) ?? - providers.find((entry) => entry.id === resolveSearchProvider(search)) ?? - providers[0]; - if (!provider) { - return null; - } - - const definition = provider.createTool({ - config: options?.config, - searchConfig: search as Record | undefined, - runtimeMetadata: options?.runtimeWebSearch, - }); - if (!definition) { - return null; - } - - return { - label: "Web Search", - name: "web_search", - description: definition.description, - parameters: definition.parameters, - execute: async (_toolCallId, args) => jsonResult(await definition.execute(args)), - }; + return createWebSearchToolCore(options); } export const __testing = { ...coreTesting, - resolveSearchProvider, + resolveSearchProvider: ( + search?: OpenClawConfig["tools"] extends infer Tools + ? Tools extends { web?: infer Web } + ? Web extends { search?: infer Search } + ? Search + : undefined + : undefined + : undefined, + ) => runtimeTesting.resolveWebSearchProviderId({ search }), }; diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index c416804fa11..d06f65e0deb 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -325,9 +325,16 @@ describe("web_search provider proxy dispatch", () => { describe("web_search perplexity Search API", () => { const priorFetch = global.fetch; + const savedEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.PERPLEXITY_API_KEY; + delete process.env.OPENROUTER_API_KEY; + }); afterEach(() => { vi.unstubAllEnvs(); + process.env = { ...savedEnv }; global.fetch = priorFetch; webSearchTesting.SEARCH_CACHE.clear(); }); @@ -462,9 +469,16 @@ describe("web_search perplexity Search API", () => { describe("web_search perplexity OpenRouter compatibility", () => { const priorFetch = global.fetch; + const savedEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.PERPLEXITY_API_KEY; + delete process.env.OPENROUTER_API_KEY; + }); afterEach(() => { vi.unstubAllEnvs(); + process.env = { ...savedEnv }; global.fetch = priorFetch; webSearchTesting.SEARCH_CACHE.clear(); }); diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 9f7613881a5..2022ac07d37 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -2,14 +2,8 @@ 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 * as execModule from "../../process/exec.js"; import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; - -const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../process/exec.js", () => ({ - runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), -})); - import { clearGatewaySubagentRuntime, createPluginRuntime, @@ -18,20 +12,24 @@ import { describe("plugin runtime command execution", () => { beforeEach(() => { - runCommandWithTimeoutMock.mockClear(); + vi.restoreAllMocks(); clearGatewaySubagentRuntime(); }); it("exposes runtime.system.runCommandWithTimeout by default", async () => { const commandResult = { + pid: 12345, stdout: "hello\n", stderr: "", code: 0, signal: null, killed: false, + noOutputTimedOut: false, termination: "exit" as const, }; - runCommandWithTimeoutMock.mockResolvedValue(commandResult); + const runCommandWithTimeoutMock = vi + .spyOn(execModule, "runCommandWithTimeout") + .mockResolvedValue(commandResult); const runtime = createPluginRuntime(); await expect( @@ -41,7 +39,9 @@ describe("plugin runtime command execution", () => { }); it("forwards runtime.system.runCommandWithTimeout errors", async () => { - runCommandWithTimeoutMock.mockRejectedValue(new Error("boom")); + const runCommandWithTimeoutMock = vi + .spyOn(execModule, "runCommandWithTimeout") + .mockRejectedValue(new Error("boom")); const runtime = createPluginRuntime(); await expect( runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }), @@ -63,6 +63,12 @@ describe("plugin runtime command execution", () => { expect(runtime.mediaUnderstanding.transcribeAudioFile).toBe(runtime.stt.transcribeAudioFile); }); + it("exposes runtime.webSearch helpers", () => { + const runtime = createPluginRuntime(); + expect(typeof runtime.webSearch.listProviders).toBe("function"); + expect(typeof runtime.webSearch.search).toBe("function"); + }); + it("exposes runtime.system.requestHeartbeatNow", () => { const runtime = createPluginRuntime(); expect(runtime.system.requestHeartbeatNow).toBe(requestHeartbeatNow); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 48899303e2f..cd76a21916b 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -11,6 +11,7 @@ import { transcribeAudioFile, } from "../../media-understanding/runtime.js"; import { listSpeechVoices, textToSpeech, textToSpeechTelephony } from "../../tts/tts.js"; +import { listWebSearchProviders, runWebSearch } from "../../web-search/runtime.js"; import { createRuntimeAgent } from "./runtime-agent.js"; import { createRuntimeChannel } from "./runtime-channel.js"; import { createRuntimeConfig } from "./runtime-config.js"; @@ -147,6 +148,10 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): describeVideoFile, transcribeAudioFile, }, + webSearch: { + listProviders: listWebSearchProviders, + search: runWebSearch, + }, stt: { transcribeAudioFile }, tools: createRuntimeTools(), channel: createRuntimeChannel(), diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 822f0026b49..528c488d987 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -57,6 +57,10 @@ export type PluginRuntimeCore = { describeVideoFile: typeof import("../../media-understanding/runtime.js").describeVideoFile; transcribeAudioFile: typeof import("../../media-understanding/runtime.js").transcribeAudioFile; }; + webSearch: { + listProviders: typeof import("../../web-search/runtime.js").listWebSearchProviders; + search: typeof import("../../web-search/runtime.js").runWebSearch; + }; stt: { transcribeAudioFile: typeof import("../../media-understanding/transcribe-audio.js").transcribeAudioFile; }; diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index 52e326ddc04..9d2fd18e030 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -1,7 +1,16 @@ -import { describe, expect, it } from "vitest"; -import { resolvePluginWebSearchProviders } from "./web-search-providers.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "./registry.js"; +import { setActivePluginRegistry } from "./runtime.js"; +import { + resolvePluginWebSearchProviders, + resolveRuntimeWebSearchProviders, +} from "./web-search-providers.js"; describe("resolvePluginWebSearchProviders", () => { + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + it("returns bundled providers in auto-detect order", () => { const providers = resolvePluginWebSearchProviders({}); @@ -72,4 +81,36 @@ describe("resolvePluginWebSearchProviders", () => { expect(providers).toEqual([]); }); + + it("prefers the active plugin registry for runtime resolution", () => { + const registry = createEmptyPluginRegistry(); + registry.webSearchProviders.push({ + pluginId: "custom-search", + pluginName: "Custom Search", + provider: { + id: "custom", + label: "Custom Search", + hint: "Custom runtime provider", + envVars: ["CUSTOM_SEARCH_API_KEY"], + placeholder: "custom-...", + signupUrl: "https://example.com/signup", + autoDetectOrder: 1, + getCredentialValue: () => "configured", + setCredentialValue: () => {}, + createTool: () => ({ + description: "custom", + parameters: {}, + execute: async () => ({}), + }), + }, + source: "test", + }); + setActivePluginRegistry(registry); + + const providers = resolveRuntimeWebSearchProviders({}); + + expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ + "custom-search:custom", + ]); + }); }); diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 97b6d9ee022..8aba087f1fc 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -12,6 +12,7 @@ import { } from "./bundled-compat.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; +import { getActivePluginRegistry } from "./runtime.js"; import type { PluginWebSearchProviderEntry } from "./types.js"; const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ @@ -127,25 +128,47 @@ export function resolvePluginWebSearchProviders(params: { }); const normalizedPlugins = normalizePluginsConfig(config?.plugins); - return BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY.filter( - ({ pluginId }) => - resolveEffectiveEnableState({ - id: pluginId, - origin: "bundled", - config: normalizedPlugins, - rootConfig: config, - }).enabled, - ) - .map((entry) => ({ + return sortWebSearchProviders( + BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY.filter( + ({ pluginId }) => + resolveEffectiveEnableState({ + id: pluginId, + origin: "bundled", + config: normalizedPlugins, + rootConfig: config, + }).enabled, + ).map((entry) => ({ ...entry.provider, pluginId: entry.pluginId, - })) - .toSorted((a, b) => { - const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - if (aOrder !== bOrder) { - return aOrder - bOrder; - } - return a.id.localeCompare(b.id); - }); + })), + ); +} + +function sortWebSearchProviders( + providers: PluginWebSearchProviderEntry[], +): PluginWebSearchProviderEntry[] { + return providers.toSorted((a, b) => { + const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + if (aOrder !== bOrder) { + return aOrder - bOrder; + } + return a.id.localeCompare(b.id); + }); +} + +export function resolveRuntimeWebSearchProviders(params: { + config?: PluginLoadOptions["config"]; + bundledAllowlistCompat?: boolean; +}): PluginWebSearchProviderEntry[] { + const runtimeProviders = getActivePluginRegistry()?.webSearchProviders ?? []; + if (runtimeProviders.length > 0) { + return sortWebSearchProviders( + runtimeProviders.map((entry) => ({ + ...entry.provider, + pluginId: entry.pluginId, + })), + ); + } + return resolvePluginWebSearchProviders(params); } diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts new file mode 100644 index 00000000000..68446d33a95 --- /dev/null +++ b/src/web-search/runtime.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { runWebSearch } from "./runtime.js"; + +describe("web search runtime", () => { + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("executes searches through the active plugin registry", async () => { + const registry = createEmptyPluginRegistry(); + registry.webSearchProviders.push({ + pluginId: "custom-search", + pluginName: "Custom Search", + provider: { + id: "custom", + label: "Custom Search", + hint: "Custom runtime provider", + envVars: ["CUSTOM_SEARCH_API_KEY"], + placeholder: "custom-...", + signupUrl: "https://example.com/signup", + autoDetectOrder: 1, + getCredentialValue: () => "configured", + setCredentialValue: () => {}, + createTool: () => ({ + description: "custom", + parameters: {}, + execute: async (args) => ({ ...args, ok: true }), + }), + }, + source: "test", + }); + setActivePluginRegistry(registry); + + await expect( + runWebSearch({ + config: {}, + args: { query: "hello" }, + }), + ).resolves.toEqual({ + provider: "custom", + result: { query: "hello", ok: true }, + }); + }); +}); diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts new file mode 100644 index 00000000000..cf11dfcb667 --- /dev/null +++ b/src/web-search/runtime.ts @@ -0,0 +1,194 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { normalizeResolvedSecretInputString } from "../config/types.secrets.js"; +import { logVerbose } from "../globals.js"; +import type { + PluginWebSearchProviderEntry, + WebSearchProviderToolDefinition, +} from "../plugins/types.js"; +import { + resolvePluginWebSearchProviders, + resolveRuntimeWebSearchProviders, +} from "../plugins/web-search-providers.js"; +import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; +import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; + +type WebSearchConfig = NonNullable["web"] extends infer Web + ? Web extends { search?: infer Search } + ? Search + : undefined + : undefined; + +export type ResolveWebSearchDefinitionParams = { + config?: OpenClawConfig; + sandboxed?: boolean; + runtimeWebSearch?: RuntimeWebSearchMetadata; + providerId?: string; + preferRuntimeProviders?: boolean; +}; + +export type RunWebSearchParams = ResolveWebSearchDefinitionParams & { + args: Record; +}; + +function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { + const search = cfg?.tools?.web?.search; + if (!search || typeof search !== "object") { + return undefined; + } + return search as WebSearchConfig; +} + +export function resolveWebSearchEnabled(params: { + search?: WebSearchConfig; + sandboxed?: boolean; +}): boolean { + if (typeof params.search?.enabled === "boolean") { + return params.search.enabled; + } + if (params.sandboxed) { + return true; + } + return true; +} + +function readProviderEnvValue(envVars: string[]): string | undefined { + for (const envVar of envVars) { + const value = normalizeSecretInput(process.env[envVar]); + if (value) { + return value; + } + } + return undefined; +} + +function hasProviderCredential(providerId: string, search: WebSearchConfig | undefined): boolean { + const providers = resolvePluginWebSearchProviders({ + bundledAllowlistCompat: true, + }); + const provider = providers.find((entry) => entry.id === providerId); + if (!provider) { + return false; + } + const rawValue = provider.getCredentialValue(search as Record | undefined); + const fromConfig = normalizeSecretInput( + normalizeResolvedSecretInputString({ + value: rawValue, + path: + providerId === "brave" + ? "tools.web.search.apiKey" + : `tools.web.search.${providerId}.apiKey`, + }), + ); + return Boolean(fromConfig || readProviderEnvValue(provider.envVars)); +} + +export function listWebSearchProviders(params?: { + config?: OpenClawConfig; +}): PluginWebSearchProviderEntry[] { + return resolveRuntimeWebSearchProviders({ + config: params?.config, + bundledAllowlistCompat: true, + }); +} + +export function resolveWebSearchProviderId(params: { + search?: WebSearchConfig; + providers?: PluginWebSearchProviderEntry[]; +}): string { + const providers = + params.providers ?? + resolvePluginWebSearchProviders({ + bundledAllowlistCompat: true, + }); + const raw = + params.search && "provider" in params.search && typeof params.search.provider === "string" + ? params.search.provider.trim().toLowerCase() + : ""; + + if (raw) { + const explicit = providers.find((provider) => provider.id === raw); + if (explicit) { + return explicit.id; + } + } + + if (!raw) { + for (const provider of providers) { + if (!hasProviderCredential(provider.id, params.search)) { + continue; + } + logVerbose( + `web_search: no provider configured, auto-detected "${provider.id}" from available API keys`, + ); + return provider.id; + } + } + + return providers[0]?.id ?? "brave"; +} + +export function resolveWebSearchDefinition( + options?: ResolveWebSearchDefinitionParams, +): { provider: PluginWebSearchProviderEntry; definition: WebSearchProviderToolDefinition } | null { + const search = resolveSearchConfig(options?.config); + if (!resolveWebSearchEnabled({ search, sandboxed: options?.sandboxed })) { + return null; + } + + const providers = ( + options?.preferRuntimeProviders + ? resolveRuntimeWebSearchProviders({ + config: options?.config, + bundledAllowlistCompat: true, + }) + : resolvePluginWebSearchProviders({ + config: options?.config, + bundledAllowlistCompat: true, + }) + ).filter(Boolean); + if (providers.length === 0) { + return null; + } + + const providerId = + options?.providerId ?? + options?.runtimeWebSearch?.selectedProvider ?? + options?.runtimeWebSearch?.providerConfigured ?? + resolveWebSearchProviderId({ search, providers }); + const provider = + providers.find((entry) => entry.id === providerId) ?? + providers.find((entry) => entry.id === resolveWebSearchProviderId({ search, providers })) ?? + providers[0]; + if (!provider) { + return null; + } + + const definition = provider.createTool({ + config: options?.config, + searchConfig: search as Record | undefined, + runtimeMetadata: options?.runtimeWebSearch, + }); + if (!definition) { + return null; + } + + return { provider, definition }; +} + +export async function runWebSearch( + params: RunWebSearchParams, +): Promise<{ provider: string; result: Record }> { + const resolved = resolveWebSearchDefinition({ ...params, preferRuntimeProviders: true }); + if (!resolved) { + throw new Error("web_search is disabled or no provider is available."); + } + return { + provider: resolved.provider.id, + result: await resolved.definition.execute(params.args), + }; +} + +export const __testing = { + resolveSearchConfig, + resolveWebSearchProviderId, +}; From 631f6f47cfb1a7a0ccd96310f2ad12fb9f353cf8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:30:50 -0700 Subject: [PATCH 090/128] fix(extensions): restore setup and catalog tests --- .../signal/src/setup-allow-from.test.ts | 2 +- extensions/signal/src/setup-surface.ts | 2 -- extensions/whatsapp/src/channel.ts | 4 ++++ src/plugins/provider-catalog.test.ts | 22 +++++++------------ 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/extensions/signal/src/setup-allow-from.test.ts b/extensions/signal/src/setup-allow-from.test.ts index 959082a2582..c7532870109 100644 --- a/extensions/signal/src/setup-allow-from.test.ts +++ b/extensions/signal/src/setup-allow-from.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { normalizeSignalAccountInput, parseSignalAllowFromEntries } from "./setup-surface.js"; +import { normalizeSignalAccountInput, parseSignalAllowFromEntries } from "./setup-core.js"; describe("normalizeSignalAccountInput", () => { it("normalizes valid E.164 numbers", () => { diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 32270cde952..72b1a4ef958 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,11 +1,9 @@ import { - DEFAULT_ACCOUNT_ID, detectBinary, formatCliCommand, formatDocsLink, installSignalCli, type OpenClawConfig, - parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 7e4be853c23..ae7a6b1e56c 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -41,6 +41,10 @@ import { collectWhatsAppStatusIssues } from "./status-issues.js"; const meta = getChatChannelMeta("whatsapp"); +async function loadWhatsAppChannelRuntime() { + return await import("./channel.runtime.js"); +} + function normalizeWhatsAppPayloadText(text: string | undefined): string { return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); } diff --git a/src/plugins/provider-catalog.test.ts b/src/plugins/provider-catalog.test.ts index b8c865dec5d..a49e82a98e6 100644 --- a/src/plugins/provider-catalog.test.ts +++ b/src/plugins/provider-catalog.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import type { ModelProviderConfig } from "../config/types.models.js"; import { buildPairedProviderApiKeyCatalog, buildSingleProviderApiKeyCatalog, @@ -7,12 +8,12 @@ import { } from "./provider-catalog.js"; import type { ProviderCatalogContext } from "./types.js"; -function createProviderConfig(params?: { provider?: string; baseUrl?: string }) { +function createProviderConfig(overrides: Partial = {}): ModelProviderConfig { return { - api: "openai-completions" as const, - provider: params?.provider ?? "test-provider", - baseUrl: params?.baseUrl ?? "https://default.example/v1", + api: "openai-completions", + baseUrl: "https://default.example/v1", models: [], + ...overrides, }; } @@ -67,7 +68,6 @@ describe("buildSingleProviderApiKeyCatalog", () => { api: "openai-completions", baseUrl: "https://default.example/v1", models: [], - provider: "test-provider", apiKey: "secret-key", }, }); @@ -89,10 +89,7 @@ describe("buildSingleProviderApiKeyCatalog", () => { }, }), providerId: "test-provider", - buildProvider: () => ({ - ...createProviderConfig(), - baseUrl: "https://default.example/v1", - }), + buildProvider: () => createProviderConfig(), allowExplicitBaseUrl: true, }); @@ -101,7 +98,6 @@ describe("buildSingleProviderApiKeyCatalog", () => { api: "openai-completions", baseUrl: "https://override.example/v1/", models: [], - provider: "test-provider", apiKey: "secret-key", }, }); @@ -114,8 +110,8 @@ describe("buildSingleProviderApiKeyCatalog", () => { }), providerId: "test-provider", buildProviders: async () => ({ - alpha: createProviderConfig({ provider: "alpha" }), - beta: createProviderConfig({ provider: "beta" }), + alpha: createProviderConfig(), + beta: createProviderConfig(), }), }); @@ -125,14 +121,12 @@ describe("buildSingleProviderApiKeyCatalog", () => { api: "openai-completions", baseUrl: "https://default.example/v1", models: [], - provider: "alpha", apiKey: "secret-key", }, beta: { api: "openai-completions", baseUrl: "https://default.example/v1", models: [], - provider: "beta", apiKey: "secret-key", }, }, From 73703d977cad5d4409e7bc7d7a3e29edd29eeafd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:33:35 -0700 Subject: [PATCH 091/128] refactor: remove onboard auth compat barrels --- extensions/kimi-coding/index.ts | 7 +- extensions/kimi-coding/onboard.ts | 13 +- extensions/whatsapp/src/channel.ts | 2 +- extensions/whatsapp/src/plugin-shared.ts | 2 +- .../models-config.providers.moonshot.test.ts | 2 +- src/commands/auth-choice.test.ts | 10 +- src/commands/auth-credentials.ts | 6 - src/commands/auth-profile-config.ts | 1 - .../onboard-auth.config-core.kilocode.test.ts | 14 +- src/commands/onboard-auth.config-core.ts | 82 ---------- src/commands/onboard-auth.config-minimax.ts | 6 - .../onboard-auth.config-opencode-go.ts | 5 - src/commands/onboard-auth.config-opencode.ts | 5 - .../onboard-auth.config-shared.test.ts | 2 +- src/commands/onboard-auth.config-shared.ts | 7 - src/commands/onboard-auth.credentials.test.ts | 2 +- src/commands/onboard-auth.credentials.ts | 43 ----- src/commands/onboard-auth.models.ts | 140 ---------------- src/commands/onboard-auth.test.ts | 64 +++++--- src/commands/onboard-auth.ts | 149 ------------------ ...oard-non-interactive.provider-auth.test.ts | 6 +- .../local/auth-choice.api-key-providers.ts | 8 +- src/plugin-sdk/provider-models.ts | 2 +- 23 files changed, 74 insertions(+), 504 deletions(-) delete mode 100644 src/commands/auth-credentials.ts delete mode 100644 src/commands/auth-profile-config.ts delete mode 100644 src/commands/onboard-auth.config-core.ts delete mode 100644 src/commands/onboard-auth.config-minimax.ts delete mode 100644 src/commands/onboard-auth.config-opencode-go.ts delete mode 100644 src/commands/onboard-auth.config-opencode.ts delete mode 100644 src/commands/onboard-auth.config-shared.ts delete mode 100644 src/commands/onboard-auth.credentials.ts delete mode 100644 src/commands/onboard-auth.models.ts delete mode 100644 src/commands/onboard-auth.ts diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index 3803a0af951..03f680a5c38 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -4,10 +4,11 @@ import { isRecord } from "openclaw/plugin-sdk/text-runtime"; import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "./onboard.js"; import { buildKimiCodingProvider } from "./provider-catalog.js"; -const PROVIDER_ID = "kimi-coding"; +const PLUGIN_ID = "kimi"; +const PROVIDER_ID = "kimi"; const kimiCodingPlugin = { - id: PROVIDER_ID, + id: PLUGIN_ID, name: "Kimi Provider", description: "Bundled Kimi provider plugin", configSchema: emptyPluginConfigSchema(), @@ -15,7 +16,7 @@ const kimiCodingPlugin = { api.registerProvider({ id: PROVIDER_ID, label: "Kimi", - aliases: ["kimi", "kimi-code"], + aliases: ["kimi-code", "kimi-coding"], docsPath: "/providers/moonshot", envVars: ["KIMI_API_KEY", "KIMICODE_API_KEY"], auth: [ diff --git a/extensions/kimi-coding/onboard.ts b/extensions/kimi-coding/onboard.ts index c97738f1e72..60ce12553f1 100644 --- a/extensions/kimi-coding/onboard.ts +++ b/extensions/kimi-coding/onboard.ts @@ -9,13 +9,14 @@ import { KIMI_CODING_DEFAULT_MODEL_ID, } from "./provider-catalog.js"; -export const KIMI_CODING_MODEL_REF = `kimi-coding/${KIMI_CODING_DEFAULT_MODEL_ID}`; +export const KIMI_MODEL_REF = `kimi/${KIMI_CODING_DEFAULT_MODEL_ID}`; +export const KIMI_CODING_MODEL_REF = KIMI_MODEL_REF; export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; - models[KIMI_CODING_MODEL_REF] = { - ...models[KIMI_CODING_MODEL_REF], - alias: models[KIMI_CODING_MODEL_REF]?.alias ?? "Kimi", + models[KIMI_MODEL_REF] = { + ...models[KIMI_MODEL_REF], + alias: models[KIMI_MODEL_REF]?.alias ?? "Kimi", }; const defaultModel = buildKimiCodingProvider().models[0]; @@ -25,7 +26,7 @@ export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig return applyProviderConfigWithDefaultModel(cfg, { agentModels: models, - providerId: "kimi-coding", + providerId: "kimi", api: "anthropic-messages", baseUrl: KIMI_CODING_BASE_URL, defaultModel, @@ -34,5 +35,5 @@ export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig } export function applyKimiCodeConfig(cfg: OpenClawConfig): OpenClawConfig { - return applyAgentDefaultModelPrimary(applyKimiCodeProviderConfig(cfg), KIMI_CODING_MODEL_REF); + return applyAgentDefaultModelPrimary(applyKimiCodeProviderConfig(cfg), KIMI_MODEL_REF); } diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index ae7a6b1e56c..63d222ba1ed 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -34,7 +34,7 @@ import { type ResolvedWhatsAppAccount, } from "./accounts.js"; import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; -import { whatsappSetupWizardProxy } from "./plugin-shared.js"; +import { loadWhatsAppChannelRuntime, whatsappSetupWizardProxy } from "./plugin-shared.js"; import { getWhatsAppRuntime } from "./runtime.js"; import { whatsappSetupAdapter } from "./setup-core.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; diff --git a/extensions/whatsapp/src/plugin-shared.ts b/extensions/whatsapp/src/plugin-shared.ts index 96a5f86e6f9..fee78e620a4 100644 --- a/extensions/whatsapp/src/plugin-shared.ts +++ b/extensions/whatsapp/src/plugin-shared.ts @@ -1,7 +1,7 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/whatsapp"; import { type ResolvedWhatsAppAccount } from "./accounts.js"; -async function loadWhatsAppChannelRuntime() { +export async function loadWhatsAppChannelRuntime() { return await import("./channel.runtime.js"); } diff --git a/src/agents/models-config.providers.moonshot.test.ts b/src/agents/models-config.providers.moonshot.test.ts index 1d0d29d1b30..b224d1c44d3 100644 --- a/src/agents/models-config.providers.moonshot.test.ts +++ b/src/agents/models-config.providers.moonshot.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { MOONSHOT_BASE_URL as MOONSHOT_AI_BASE_URL, MOONSHOT_CN_BASE_URL, -} from "../commands/onboard-auth.models.js"; +} from "../plugins/provider-model-definitions.js"; import { captureEnv } from "../test-utils/env.js"; import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; import { applyNativeStreamingUsageCompat } from "./models-config.providers.js"; diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 038c672ee14..a394bf00528 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -26,16 +26,16 @@ import { setDetectZaiEndpointForTesting } from "../../extensions/zai/detect.js"; import zaiPlugin from "../../extensions/zai/index.js"; import { resolveAgentDir } from "../agents/agent-scope.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import { + MINIMAX_CN_API_BASE_URL, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_GLOBAL_BASE_URL, +} from "../plugins/provider-model-definitions.js"; import type { ProviderPlugin } from "../plugins/types.js"; import { createCapturedPluginRegistration } from "../test-utils/plugin-registration.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; import { GOOGLE_GEMINI_DEFAULT_MODEL } from "./google-gemini-model-default.js"; -import { - MINIMAX_CN_API_BASE_URL, - ZAI_CODING_CN_BASE_URL, - ZAI_CODING_GLOBAL_BASE_URL, -} from "./onboard-auth.js"; import type { AuthChoice } from "./onboard-types.js"; import { authProfilePathForAgent, diff --git a/src/commands/auth-credentials.ts b/src/commands/auth-credentials.ts deleted file mode 100644 index 94e320f48db..00000000000 --- a/src/commands/auth-credentials.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - buildApiKeyCredential, - type ApiKeyStorageOptions, - writeOAuthCredentials, - type WriteOAuthCredentialsOptions, -} from "../plugins/provider-auth-helpers.js"; diff --git a/src/commands/auth-profile-config.ts b/src/commands/auth-profile-config.ts deleted file mode 100644 index c3879e01846..00000000000 --- a/src/commands/auth-profile-config.ts +++ /dev/null @@ -1 +0,0 @@ -export { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; diff --git a/src/commands/onboard-auth.config-core.kilocode.test.ts b/src/commands/onboard-auth.config-core.kilocode.test.ts index 82faf85c8f0..511b5550890 100644 --- a/src/commands/onboard-auth.config-core.kilocode.test.ts +++ b/src/commands/onboard-auth.config-core.kilocode.test.ts @@ -2,23 +2,23 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveApiKeyForProvider, resolveEnvApiKey } from "../agents/model-auth.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; -import { captureEnv } from "../test-utils/env.js"; import { applyKilocodeProviderConfig, applyKilocodeConfig, KILOCODE_BASE_URL, -} from "./onboard-auth.config-core.js"; -import { KILOCODE_DEFAULT_MODEL_REF } from "./onboard-auth.credentials.js"; + KILOCODE_DEFAULT_MODEL_REF, +} from "../../extensions/kilocode/onboard.js"; +import { resolveApiKeyForProvider, resolveEnvApiKey } from "../agents/model-auth.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import { buildKilocodeModelDefinition, KILOCODE_DEFAULT_MODEL_ID, KILOCODE_DEFAULT_CONTEXT_WINDOW, KILOCODE_DEFAULT_MAX_TOKENS, KILOCODE_DEFAULT_COST, -} from "./onboard-auth.models.js"; +} from "../plugins/provider-model-definitions.js"; +import { captureEnv } from "../test-utils/env.js"; const emptyCfg: OpenClawConfig = {}; const KILOCODE_MODEL_IDS = ["kilo/auto"]; diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts deleted file mode 100644 index 65b4fd40cf0..00000000000 --- a/src/commands/onboard-auth.config-core.ts +++ /dev/null @@ -1,82 +0,0 @@ -export { - applyCloudflareAiGatewayConfig, - applyCloudflareAiGatewayProviderConfig, - applyVercelAiGatewayConfig, - applyVercelAiGatewayProviderConfig, -} from "./onboard-auth.config-gateways.js"; -export { - applyLitellmConfig, - applyLitellmProviderConfig, - LITELLM_BASE_URL, - LITELLM_DEFAULT_MODEL_ID, -} from "./onboard-auth.config-litellm.js"; -export { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; -export { - applyHuggingfaceConfig, - applyHuggingfaceProviderConfig, - HUGGINGFACE_DEFAULT_MODEL_REF, -} from "../../extensions/huggingface/onboard.js"; -export { - applyKimiCodeConfig, - applyKimiCodeProviderConfig, -} from "../../extensions/kimi-coding/onboard.js"; -export { - applyKilocodeConfig, - applyKilocodeProviderConfig, - KILOCODE_BASE_URL, - KILOCODE_DEFAULT_MODEL_REF, -} from "../../extensions/kilocode/onboard.js"; -export { - applyMistralConfig, - applyMistralProviderConfig, - MISTRAL_DEFAULT_MODEL_REF, -} from "../../extensions/mistral/onboard.js"; -export { - applyModelStudioConfig, - applyModelStudioConfigCn, - applyModelStudioProviderConfig, - applyModelStudioProviderConfigCn, - MODELSTUDIO_CN_BASE_URL, - MODELSTUDIO_DEFAULT_MODEL_REF, - MODELSTUDIO_GLOBAL_BASE_URL, -} from "../../extensions/modelstudio/onboard.js"; -export { - applyMoonshotConfig, - applyMoonshotConfigCn, - applyMoonshotProviderConfig, - applyMoonshotProviderConfigCn, -} from "../../extensions/moonshot/onboard.js"; -export { - applyOpenrouterConfig, - applyOpenrouterProviderConfig, -} from "../../extensions/openrouter/onboard.js"; -export { - applyQianfanConfig, - applyQianfanProviderConfig, -} from "../../extensions/qianfan/onboard.js"; -export { - applySyntheticConfig, - applySyntheticProviderConfig, - SYNTHETIC_DEFAULT_MODEL_REF, -} from "../../extensions/synthetic/onboard.js"; -export { - applyTogetherConfig, - applyTogetherProviderConfig, - TOGETHER_DEFAULT_MODEL_REF, -} from "../../extensions/together/onboard.js"; -export { - applyVeniceConfig, - applyVeniceProviderConfig, - VENICE_DEFAULT_MODEL_REF, -} from "../../extensions/venice/onboard.js"; -export { - applyXaiConfig, - applyXaiProviderConfig, - XAI_DEFAULT_MODEL_REF, -} from "../../extensions/xai/onboard.js"; -export { applyXiaomiConfig, applyXiaomiProviderConfig } from "../../extensions/xiaomi/onboard.js"; -export { - applyZaiConfig, - applyZaiProviderConfig, - ZAI_DEFAULT_MODEL_REF, -} from "../../extensions/zai/onboard.js"; diff --git a/src/commands/onboard-auth.config-minimax.ts b/src/commands/onboard-auth.config-minimax.ts deleted file mode 100644 index 8453154bb7f..00000000000 --- a/src/commands/onboard-auth.config-minimax.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - applyMinimaxApiConfig, - applyMinimaxApiConfigCn, - applyMinimaxApiProviderConfig, - applyMinimaxApiProviderConfigCn, -} from "../../extensions/minimax/onboard.js"; diff --git a/src/commands/onboard-auth.config-opencode-go.ts b/src/commands/onboard-auth.config-opencode-go.ts deleted file mode 100644 index eb31512e565..00000000000 --- a/src/commands/onboard-auth.config-opencode-go.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - applyOpencodeGoConfig, - applyOpencodeGoProviderConfig, - OPENCODE_GO_DEFAULT_MODEL_REF, -} from "../../extensions/opencode-go/onboard.js"; diff --git a/src/commands/onboard-auth.config-opencode.ts b/src/commands/onboard-auth.config-opencode.ts deleted file mode 100644 index d9aa6f97436..00000000000 --- a/src/commands/onboard-auth.config-opencode.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - applyOpencodeZenConfig, - applyOpencodeZenProviderConfig, - OPENCODE_ZEN_DEFAULT_MODEL_REF, -} from "../../extensions/opencode/onboard.js"; diff --git a/src/commands/onboard-auth.config-shared.test.ts b/src/commands/onboard-auth.config-shared.test.ts index de2dc9adb62..01cda96ae74 100644 --- a/src/commands/onboard-auth.config-shared.test.ts +++ b/src/commands/onboard-auth.config-shared.test.ts @@ -6,7 +6,7 @@ import { applyProviderConfigWithDefaultModel, applyProviderConfigWithDefaultModels, applyProviderConfigWithModelCatalog, -} from "./onboard-auth.config-shared.js"; +} from "../plugins/provider-onboarding-config.js"; function makeModel(id: string): ModelDefinitionConfig { return { diff --git a/src/commands/onboard-auth.config-shared.ts b/src/commands/onboard-auth.config-shared.ts deleted file mode 100644 index 7c278eec644..00000000000 --- a/src/commands/onboard-auth.config-shared.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - applyAgentDefaultModelPrimary, - applyOnboardAuthAgentModelsAndProviders, - applyProviderConfigWithDefaultModel, - applyProviderConfigWithDefaultModels, - applyProviderConfigWithModelCatalog, -} from "../plugins/provider-onboarding-config.js"; diff --git a/src/commands/onboard-auth.credentials.test.ts b/src/commands/onboard-auth.credentials.test.ts index e844ac501c2..8c80f51ec2a 100644 --- a/src/commands/onboard-auth.credentials.test.ts +++ b/src/commands/onboard-auth.credentials.test.ts @@ -6,7 +6,7 @@ import { setOpencodeZenApiKey, setOpenaiApiKey, setVolcengineApiKey, -} from "./onboard-auth.js"; +} from "../plugins/provider-auth-storage.js"; import { createAuthTestLifecycle, readAuthProfilesForAgent, diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts deleted file mode 100644 index 578ad17859d..00000000000 --- a/src/commands/onboard-auth.credentials.ts +++ /dev/null @@ -1,43 +0,0 @@ -export { - buildApiKeyCredential, - type ApiKeyStorageOptions, - HUGGINGFACE_DEFAULT_MODEL_REF, - KILOCODE_DEFAULT_MODEL_REF, - LITELLM_DEFAULT_MODEL_REF, - OPENROUTER_DEFAULT_MODEL_REF, - setAnthropicApiKey, - setByteplusApiKey, - setCloudflareAiGatewayConfig, - setGeminiApiKey, - setHuggingfaceApiKey, - setKilocodeApiKey, - setKimiCodingApiKey, - setLitellmApiKey, - setMinimaxApiKey, - setMistralApiKey, - setModelStudioApiKey, - setMoonshotApiKey, - setOpenaiApiKey, - setOpencodeGoApiKey, - setOpencodeZenApiKey, - setOpenrouterApiKey, - setQianfanApiKey, - setSyntheticApiKey, - setTogetherApiKey, - setVeniceApiKey, - setVercelAiGatewayApiKey, - setVolcengineApiKey, - setXaiApiKey, - setXiaomiApiKey, - setZaiApiKey, - TOGETHER_DEFAULT_MODEL_REF, - VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, - writeOAuthCredentials, - type WriteOAuthCredentialsOptions, - XIAOMI_DEFAULT_MODEL_REF, - ZAI_DEFAULT_MODEL_REF, -} from "../plugins/provider-auth-storage.js"; -export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js"; -export { MISTRAL_DEFAULT_MODEL_REF } from "../../extensions/mistral/onboard.js"; -export { MODELSTUDIO_DEFAULT_MODEL_REF } from "../../extensions/modelstudio/onboard.js"; -export { XAI_DEFAULT_MODEL_REF } from "../../extensions/xai/onboard.js"; diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts deleted file mode 100644 index 5788d0ad2ca..00000000000 --- a/src/commands/onboard-auth.models.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { KIMI_CODING_MODEL_REF } from "../../extensions/kimi-coding/onboard.js"; -import { - KIMI_DEFAULT_MODEL_ID as KIMI_CODING_MODEL_ID, - KIMI_CODING_BASE_URL, -} from "../../extensions/kimi-coding/provider-catalog.js"; -import { - DEFAULT_MINIMAX_BASE_URL, - MINIMAX_API_BASE_URL, - MINIMAX_API_COST, - MINIMAX_CN_API_BASE_URL, - MINIMAX_HOSTED_COST, - MINIMAX_HOSTED_MODEL_ID, - MINIMAX_HOSTED_MODEL_REF, - MINIMAX_LM_STUDIO_COST, - buildMinimaxApiModelDefinition, - buildMinimaxModelDefinition, -} from "../../extensions/minimax/model-definitions.js"; -import { - buildMistralModelDefinition, - MISTRAL_BASE_URL, - MISTRAL_DEFAULT_COST, - MISTRAL_DEFAULT_MODEL_ID, - MISTRAL_DEFAULT_MODEL_REF, -} from "../../extensions/mistral/model-definitions.js"; -import { - MODELSTUDIO_CN_BASE_URL, - MODELSTUDIO_DEFAULT_COST, - MODELSTUDIO_DEFAULT_MODEL_ID, - MODELSTUDIO_DEFAULT_MODEL_REF, - MODELSTUDIO_GLOBAL_BASE_URL, - buildModelStudioDefaultModelDefinition, - buildModelStudioModelDefinition, -} from "../../extensions/modelstudio/model-definitions.js"; -import { - MOONSHOT_CN_BASE_URL, - MOONSHOT_DEFAULT_MODEL_REF, -} from "../../extensions/moonshot/onboard.js"; -import { - buildMoonshotProvider, - MOONSHOT_BASE_URL, - MOONSHOT_DEFAULT_MODEL_ID, -} from "../../extensions/moonshot/provider-catalog.js"; -import { QIANFAN_DEFAULT_MODEL_REF } from "../../extensions/qianfan/onboard.js"; -import { - QIANFAN_BASE_URL, - QIANFAN_DEFAULT_MODEL_ID, -} from "../../extensions/qianfan/provider-catalog.js"; -import { - XAI_BASE_URL, - XAI_DEFAULT_COST, - XAI_DEFAULT_MODEL_ID, - XAI_DEFAULT_MODEL_REF, - buildXaiModelDefinition, -} from "../../extensions/xai/model-definitions.js"; -import { - buildZaiModelDefinition, - resolveZaiBaseUrl, - ZAI_CN_BASE_URL, - ZAI_CODING_CN_BASE_URL, - ZAI_CODING_GLOBAL_BASE_URL, - ZAI_DEFAULT_COST, - ZAI_DEFAULT_MODEL_ID, - ZAI_GLOBAL_BASE_URL, -} from "../../extensions/zai/model-definitions.js"; -import type { ModelDefinitionConfig } from "../config/types.models.js"; -import { - KILOCODE_DEFAULT_CONTEXT_WINDOW, - KILOCODE_DEFAULT_COST, - KILOCODE_DEFAULT_MAX_TOKENS, - KILOCODE_DEFAULT_MODEL_ID, - KILOCODE_DEFAULT_MODEL_NAME, -} from "../providers/kilocode-shared.js"; - -export { - DEFAULT_MINIMAX_BASE_URL, - MINIMAX_API_BASE_URL, - MINIMAX_API_COST, - MINIMAX_CN_API_BASE_URL, - MINIMAX_HOSTED_COST, - MINIMAX_HOSTED_MODEL_ID, - MINIMAX_HOSTED_MODEL_REF, - MINIMAX_LM_STUDIO_COST, - MISTRAL_BASE_URL, - MISTRAL_DEFAULT_COST, - MISTRAL_DEFAULT_MODEL_ID, - MISTRAL_DEFAULT_MODEL_REF, - MODELSTUDIO_CN_BASE_URL, - MODELSTUDIO_DEFAULT_COST, - MODELSTUDIO_DEFAULT_MODEL_ID, - MODELSTUDIO_DEFAULT_MODEL_REF, - MODELSTUDIO_GLOBAL_BASE_URL, - MOONSHOT_BASE_URL, - MOONSHOT_CN_BASE_URL, - MOONSHOT_DEFAULT_MODEL_ID, - MOONSHOT_DEFAULT_MODEL_REF, - QIANFAN_BASE_URL, - QIANFAN_DEFAULT_MODEL_ID, - QIANFAN_DEFAULT_MODEL_REF, - XAI_BASE_URL, - XAI_DEFAULT_COST, - XAI_DEFAULT_MODEL_ID, - XAI_DEFAULT_MODEL_REF, - ZAI_CN_BASE_URL, - ZAI_CODING_CN_BASE_URL, - ZAI_CODING_GLOBAL_BASE_URL, - ZAI_DEFAULT_COST, - ZAI_DEFAULT_MODEL_ID, - ZAI_GLOBAL_BASE_URL, - KIMI_CODING_BASE_URL, - KIMI_CODING_MODEL_ID, - KIMI_CODING_MODEL_REF, - KILOCODE_DEFAULT_CONTEXT_WINDOW, - KILOCODE_DEFAULT_COST, - KILOCODE_DEFAULT_MAX_TOKENS, - KILOCODE_DEFAULT_MODEL_ID, - buildMinimaxApiModelDefinition, - buildMinimaxModelDefinition, - buildMistralModelDefinition, - buildModelStudioDefaultModelDefinition, - buildModelStudioModelDefinition, - buildXaiModelDefinition, - buildZaiModelDefinition, - resolveZaiBaseUrl, -}; - -export function buildMoonshotModelDefinition(): ModelDefinitionConfig { - return buildMoonshotProvider().models[0]; -} - -export function buildKilocodeModelDefinition(): ModelDefinitionConfig { - return { - id: KILOCODE_DEFAULT_MODEL_ID, - name: KILOCODE_DEFAULT_MODEL_NAME, - reasoning: true, - input: ["text", "image"], - cost: KILOCODE_DEFAULT_COST, - contextWindow: KILOCODE_DEFAULT_CONTEXT_WINDOW, - maxTokens: KILOCODE_DEFAULT_MAX_TOKENS, - }; -} diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 8c4b8e38bda..2ad0339a3b2 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -3,43 +3,57 @@ import os from "node:os"; import path from "node:path"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; +import { + applyMinimaxApiConfig, + applyMinimaxApiProviderConfig, +} from "../../extensions/minimax/onboard.js"; +import { + applyMistralConfig, + applyMistralProviderConfig, +} from "../../extensions/mistral/onboard.js"; +import { + applyOpencodeGoConfig, + applyOpencodeGoProviderConfig, +} from "../../extensions/opencode-go/onboard.js"; +import { + applyOpencodeZenConfig, + applyOpencodeZenProviderConfig, +} from "../../extensions/opencode/onboard.js"; +import { + applyOpenrouterConfig, + applyOpenrouterProviderConfig, +} from "../../extensions/openrouter/onboard.js"; +import { + applySyntheticConfig, + applySyntheticProviderConfig, + SYNTHETIC_DEFAULT_MODEL_REF, +} from "../../extensions/synthetic/onboard.js"; +import { + applyXaiConfig, + applyXaiProviderConfig, + XAI_DEFAULT_MODEL_REF, +} from "../../extensions/xai/onboard.js"; +import { applyXiaomiConfig, applyXiaomiProviderConfig } from "../../extensions/xiaomi/onboard.js"; +import { applyZaiConfig, applyZaiProviderConfig } from "../../extensions/zai/onboard.js"; +import { SYNTHETIC_DEFAULT_MODEL_ID } from "../agents/synthetic-models.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, } from "../config/model-input.js"; import type { ModelApi } from "../config/types.models.js"; +import { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; import { - applyAuthProfileConfig, - applyLitellmProviderConfig, - applyMistralConfig, - applyMistralProviderConfig, - applyMinimaxApiConfig, - applyMinimaxApiProviderConfig, - applyOpencodeGoConfig, - applyOpencodeGoProviderConfig, - applyOpencodeZenConfig, - applyOpencodeZenProviderConfig, - applyOpenrouterConfig, - applyOpenrouterProviderConfig, - applySyntheticConfig, - applySyntheticProviderConfig, - applyXaiConfig, - applyXaiProviderConfig, - applyXiaomiConfig, - applyXiaomiProviderConfig, - applyZaiConfig, - applyZaiProviderConfig, OPENROUTER_DEFAULT_MODEL_REF, - MISTRAL_DEFAULT_MODEL_REF, - SYNTHETIC_DEFAULT_MODEL_ID, - SYNTHETIC_DEFAULT_MODEL_REF, - XAI_DEFAULT_MODEL_REF, setMinimaxApiKey, writeOAuthCredentials, +} from "../plugins/provider-auth-storage.js"; +import { + MISTRAL_DEFAULT_MODEL_REF, ZAI_CODING_CN_BASE_URL, ZAI_GLOBAL_BASE_URL, -} from "./onboard-auth.js"; +} from "../plugins/provider-model-definitions.js"; +import { applyLitellmProviderConfig } from "./onboard-auth.config-litellm.js"; import { createAuthTestLifecycle, readAuthProfilesForAgent, diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts deleted file mode 100644 index 9a67a69a287..00000000000 --- a/src/commands/onboard-auth.ts +++ /dev/null @@ -1,149 +0,0 @@ -export { SYNTHETIC_DEFAULT_MODEL_ID } from "../agents/synthetic-models.js"; -export { VENICE_DEFAULT_MODEL_ID } from "../agents/venice-models.js"; -export { - applyAuthProfileConfig, - applyCloudflareAiGatewayConfig, - applyCloudflareAiGatewayProviderConfig, - applyHuggingfaceConfig, - applyHuggingfaceProviderConfig, - applyKilocodeConfig, - applyKilocodeProviderConfig, - applyQianfanConfig, - applyQianfanProviderConfig, - applyKimiCodeConfig, - applyKimiCodeProviderConfig, - applyLitellmConfig, - applyLitellmProviderConfig, - applyMistralConfig, - applyMistralProviderConfig, - applyMoonshotConfig, - applyMoonshotConfigCn, - applyMoonshotProviderConfig, - applyMoonshotProviderConfigCn, - applyOpenrouterConfig, - applyOpenrouterProviderConfig, - applySyntheticConfig, - applySyntheticProviderConfig, - applyTogetherConfig, - applyTogetherProviderConfig, - applyVeniceConfig, - applyVeniceProviderConfig, - applyVercelAiGatewayConfig, - applyVercelAiGatewayProviderConfig, - applyXaiConfig, - applyXaiProviderConfig, - applyXiaomiConfig, - applyXiaomiProviderConfig, - applyZaiConfig, - applyZaiProviderConfig, - applyModelStudioConfig, - applyModelStudioConfigCn, - applyModelStudioProviderConfig, - applyModelStudioProviderConfigCn, - KILOCODE_BASE_URL, -} from "./onboard-auth.config-core.js"; -export { - applyMinimaxApiConfig, - applyMinimaxApiConfigCn, - applyMinimaxApiProviderConfig, - applyMinimaxApiProviderConfigCn, -} from "./onboard-auth.config-minimax.js"; - -export { - applyOpencodeZenConfig, - applyOpencodeZenProviderConfig, -} from "./onboard-auth.config-opencode.js"; -export { - applyOpencodeGoConfig, - applyOpencodeGoProviderConfig, -} from "./onboard-auth.config-opencode-go.js"; -export { - LITELLM_DEFAULT_MODEL_REF, - OPENROUTER_DEFAULT_MODEL_REF, - setOpenaiApiKey, - setAnthropicApiKey, - setCloudflareAiGatewayConfig, - setByteplusApiKey, - setQianfanApiKey, - setGeminiApiKey, - setKilocodeApiKey, - setLitellmApiKey, - setKimiCodingApiKey, - setMinimaxApiKey, - setMistralApiKey, - setMoonshotApiKey, - setOpencodeGoApiKey, - setOpencodeZenApiKey, - setOpenrouterApiKey, - setSyntheticApiKey, - setTogetherApiKey, - setHuggingfaceApiKey, - setVeniceApiKey, - setVercelAiGatewayApiKey, - setXiaomiApiKey, - setVolcengineApiKey, - setZaiApiKey, - setXaiApiKey, - setModelStudioApiKey, - writeOAuthCredentials, - XIAOMI_DEFAULT_MODEL_REF, -} from "./onboard-auth.credentials.js"; -export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../../extensions/cloudflare-ai-gateway/onboard.js"; -export { HUGGINGFACE_DEFAULT_MODEL_REF } from "../../extensions/huggingface/onboard.js"; -export { KILOCODE_DEFAULT_MODEL_REF } from "../../extensions/kilocode/onboard.js"; -export { MISTRAL_DEFAULT_MODEL_REF } from "../../extensions/mistral/onboard.js"; -export { MODELSTUDIO_DEFAULT_MODEL_REF } from "../../extensions/modelstudio/onboard.js"; -export { SYNTHETIC_DEFAULT_MODEL_REF } from "../../extensions/synthetic/onboard.js"; -export { TOGETHER_DEFAULT_MODEL_REF } from "../../extensions/together/onboard.js"; -export { VENICE_DEFAULT_MODEL_REF } from "../../extensions/venice/onboard.js"; -export { VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "../../extensions/vercel-ai-gateway/onboard.js"; -export { XAI_DEFAULT_MODEL_REF } from "../../extensions/xai/onboard.js"; -export { ZAI_DEFAULT_MODEL_REF } from "../../extensions/zai/onboard.js"; -export { - buildMinimaxApiModelDefinition, - buildMinimaxModelDefinition, - DEFAULT_MINIMAX_BASE_URL, - MINIMAX_API_BASE_URL, - MINIMAX_CN_API_BASE_URL, - MINIMAX_HOSTED_MODEL_ID, - MINIMAX_HOSTED_MODEL_REF, -} from "../../extensions/minimax/model-definitions.js"; -export { KIMI_DEFAULT_MODEL_ID as KIMI_CODING_MODEL_ID } from "../../extensions/kimi-coding/provider-catalog.js"; -export { KIMI_CODING_MODEL_REF } from "../../extensions/kimi-coding/onboard.js"; -export { - buildMistralModelDefinition, - MISTRAL_BASE_URL, - MISTRAL_DEFAULT_MODEL_ID, -} from "../../extensions/mistral/model-definitions.js"; -export { - MOONSHOT_BASE_URL, - MOONSHOT_DEFAULT_MODEL_ID, -} from "../../extensions/moonshot/provider-catalog.js"; -export { - MOONSHOT_CN_BASE_URL, - MOONSHOT_DEFAULT_MODEL_REF, -} from "../../extensions/moonshot/onboard.js"; -export { - QIANFAN_BASE_URL, - QIANFAN_DEFAULT_MODEL_ID, -} from "../../extensions/qianfan/provider-catalog.js"; -export { QIANFAN_DEFAULT_MODEL_REF } from "../../extensions/qianfan/onboard.js"; -export { - buildXaiModelDefinition, - XAI_BASE_URL, - XAI_DEFAULT_MODEL_ID, -} from "../../extensions/xai/model-definitions.js"; -export { - buildZaiModelDefinition, - resolveZaiBaseUrl, - ZAI_CN_BASE_URL, - ZAI_CODING_CN_BASE_URL, - ZAI_CODING_GLOBAL_BASE_URL, - ZAI_DEFAULT_MODEL_ID, - ZAI_GLOBAL_BASE_URL, -} from "../../extensions/zai/model-definitions.js"; -export { - buildKilocodeModelDefinition, - buildMoonshotModelDefinition, - KILOCODE_DEFAULT_MODEL_ID, -} from "./onboard-auth.models.js"; diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 66050fe6f62..085d9d1f102 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -2,15 +2,15 @@ import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { beforeAll, describe, expect, it, vi } from "vitest"; -import { makeTempWorkspace } from "../test-helpers/workspace.js"; -import { withEnvAsync } from "../test-utils/env.js"; import { MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL, ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL, ZAI_GLOBAL_BASE_URL, -} from "./onboard-auth.js"; +} from "../plugins/provider-model-definitions.js"; +import { makeTempWorkspace } from "../test-helpers/workspace.js"; +import { withEnvAsync } from "../test-utils/env.js"; import { createThrowingRuntime, readJsonFile, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts index 4c0454401ad..bb9ab999411 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts @@ -1,11 +1,9 @@ import type { OpenClawConfig } from "../../../config/config.js"; import type { SecretInput } from "../../../config/types.secrets.js"; +import { applyAuthProfileConfig } from "../../../plugins/provider-auth-helpers.js"; +import { setLitellmApiKey } from "../../../plugins/provider-auth-storage.js"; import type { RuntimeEnv } from "../../../runtime.js"; -import { - applyAuthProfileConfig, - applyLitellmConfig, - setLitellmApiKey, -} from "../../onboard-auth.js"; +import { applyLitellmConfig } from "../../onboard-auth.config-litellm.js"; import type { AuthChoice, OnboardOptions } from "../../onboard-types.js"; type ApiKeyStorageOptions = { diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index 5221daec1cd..f0a85fe1ed1 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -20,7 +20,7 @@ export { OPENCODE_GO_DEFAULT_MODEL_REF } from "../commands/opencode-go-model-def export { OPENCODE_ZEN_DEFAULT_MODEL } from "../commands/opencode-zen-model-default.js"; export { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; -export * from "../commands/onboard-auth.models.js"; +export * from "../plugins/provider-model-definitions.js"; export { buildCloudflareAiGatewayModelDefinition, From 0cfc80b81c121fb8ee10bcd7d72a86017e475176 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:33:50 -0700 Subject: [PATCH 092/128] refactor: finish public plugin sdk boundary seams --- .../anthropic/media-understanding-provider.ts | 6 ++++-- extensions/bluebubbles/src/setup-core.ts | 12 +++++------ .../google/media-understanding-provider.ts | 19 +++++++---------- extensions/googlechat/src/setup-core.ts | 3 +-- extensions/kilocode/index.ts | 2 +- extensions/matrix/src/setup-core.ts | 10 +++++---- .../minimax/media-understanding-provider.ts | 6 ++++-- extensions/minimax/model-definitions.ts | 2 +- .../mistral/media-understanding-provider.ts | 6 ++++-- extensions/mistral/model-definitions.ts | 2 +- extensions/modelstudio/model-definitions.ts | 2 +- .../moonshot/media-understanding-provider.ts | 12 +++++------ extensions/nvidia/index.ts | 2 +- .../openai/media-understanding-provider.ts | 13 ++++++------ extensions/qianfan/index.ts | 2 +- extensions/slack/src/shared.ts | 20 ++++++++++-------- extensions/synthetic/index.ts | 2 +- extensions/tlon/src/setup-core.ts | 11 +++++----- extensions/together/index.ts | 2 +- extensions/venice/index.ts | 2 +- extensions/vercel-ai-gateway/index.ts | 2 +- extensions/whatsapp/src/channel.runtime.ts | 2 +- extensions/xai/model-definitions.ts | 2 +- extensions/xiaomi/index.ts | 2 +- .../zai/media-understanding-provider.ts | 6 ++++-- extensions/zai/model-definitions.ts | 2 +- extensions/zalo/src/setup-core.ts | 3 +-- extensions/zalouser/src/setup-core.ts | 2 +- package.json | 12 +++++++++++ scripts/lib/plugin-sdk-entrypoints.json | 3 +++ src/agents/tools/slack-actions.ts | 2 +- src/channels/plugins/actions/signal.ts | 2 +- src/commands/doctor-config-flow.ts | 2 +- src/plugin-sdk/account-resolution.ts | 16 ++++++++++++++ src/plugin-sdk/discord.ts | 1 - src/plugin-sdk/google.ts | 4 ++++ src/plugin-sdk/media-understanding.ts | 21 +++++++++++++++++++ src/plugin-sdk/provider-catalog.ts | 9 ++++++++ src/plugin-sdk/setup.ts | 2 ++ src/plugin-sdk/signal.ts | 1 - src/plugin-sdk/slack.ts | 1 - src/plugin-sdk/telegram.ts | 1 - 42 files changed, 152 insertions(+), 82 deletions(-) create mode 100644 src/plugin-sdk/google.ts create mode 100644 src/plugin-sdk/media-understanding.ts create mode 100644 src/plugin-sdk/provider-catalog.ts diff --git a/extensions/anthropic/media-understanding-provider.ts b/extensions/anthropic/media-understanding-provider.ts index fbd12374e50..5b1f0711705 100644 --- a/extensions/anthropic/media-understanding-provider.ts +++ b/extensions/anthropic/media-understanding-provider.ts @@ -1,5 +1,7 @@ -import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; -import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; +import { + describeImageWithModel, + type MediaUnderstandingProvider, +} from "openclaw/plugin-sdk/media-understanding"; export const anthropicMediaUnderstandingProvider: MediaUnderstandingProvider = { id: "anthropic", diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts index 408cd255cf3..a8d3261b7ff 100644 --- a/extensions/bluebubbles/src/setup-core.ts +++ b/extensions/bluebubbles/src/setup-core.ts @@ -1,12 +1,12 @@ import { + normalizeAccountId, patchScopedAccountConfig, prepareScopedSetupConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; -import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DmPolicy } from "../../../src/config/types.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; + setTopLevelChannelDmPolicyWithAllowFrom, + type ChannelSetupAdapter, + type DmPolicy, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; const channel = "bluebubbles" as const; diff --git a/extensions/google/media-understanding-provider.ts b/extensions/google/media-understanding-provider.ts index 559bd4c63b8..a64f26ca6c8 100644 --- a/extensions/google/media-understanding-provider.ts +++ b/extensions/google/media-understanding-provider.ts @@ -1,18 +1,15 @@ -import { normalizeGoogleModelId } from "../../src/agents/model-id-normalization.js"; -import { parseGeminiAuth } from "../../src/infra/gemini-auth.js"; -import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import { normalizeGoogleModelId, parseGeminiAuth } from "openclaw/plugin-sdk/google"; import { assertOkOrThrowHttpError, + describeImageWithModel, normalizeBaseUrl, postJsonRequest, -} from "../../src/media-understanding/providers/shared.js"; -import type { - AudioTranscriptionRequest, - AudioTranscriptionResult, - MediaUnderstandingProvider, - VideoDescriptionRequest, - VideoDescriptionResult, -} from "../../src/media-understanding/types.js"; + type AudioTranscriptionRequest, + type AudioTranscriptionResult, + type MediaUnderstandingProvider, + type VideoDescriptionRequest, + type VideoDescriptionResult, +} from "openclaw/plugin-sdk/media-understanding"; export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; export const DEFAULT_GOOGLE_VIDEO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; diff --git a/extensions/googlechat/src/setup-core.ts b/extensions/googlechat/src/setup-core.ts index 09980bad5cd..5643ec4c291 100644 --- a/extensions/googlechat/src/setup-core.ts +++ b/extensions/googlechat/src/setup-core.ts @@ -1,5 +1,4 @@ -import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { createPatchedAccountSetupAdapter, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; const channel = "googlechat" as const; diff --git a/extensions/kilocode/index.ts b/extensions/kilocode/index.ts index c423606e552..d875bfdb3c2 100644 --- a/extensions/kilocode/index.ts +++ b/extensions/kilocode/index.ts @@ -1,10 +1,10 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { createKilocodeWrapper, isProxyReasoningUnsupported, } from "openclaw/plugin-sdk/provider-stream"; -import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { applyKilocodeConfig, KILOCODE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildKilocodeProviderWithDiscovery } from "./provider-catalog.js"; diff --git a/extensions/matrix/src/setup-core.ts b/extensions/matrix/src/setup-core.ts index 2e6bc895e0c..5e5973bd05e 100644 --- a/extensions/matrix/src/setup-core.ts +++ b/extensions/matrix/src/setup-core.ts @@ -1,7 +1,9 @@ -import { prepareScopedSetupConfig } from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { normalizeSecretInputString } from "../../../src/config/types.secrets.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import { + normalizeAccountId, + normalizeSecretInputString, + prepareScopedSetupConfig, + type ChannelSetupAdapter, +} from "openclaw/plugin-sdk/setup"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; diff --git a/extensions/minimax/media-understanding-provider.ts b/extensions/minimax/media-understanding-provider.ts index 2798bbf9593..2bda4f4d193 100644 --- a/extensions/minimax/media-understanding-provider.ts +++ b/extensions/minimax/media-understanding-provider.ts @@ -1,5 +1,7 @@ -import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; -import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; +import { + describeImageWithModel, + type MediaUnderstandingProvider, +} from "openclaw/plugin-sdk/media-understanding"; export const minimaxMediaUnderstandingProvider: MediaUnderstandingProvider = { id: "minimax", diff --git a/extensions/minimax/model-definitions.ts b/extensions/minimax/model-definitions.ts index a913a933cf7..48396f21240 100644 --- a/extensions/minimax/model-definitions.ts +++ b/extensions/minimax/model-definitions.ts @@ -1,4 +1,4 @@ -import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; +import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-models"; export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1"; export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; diff --git a/extensions/mistral/media-understanding-provider.ts b/extensions/mistral/media-understanding-provider.ts index 6ffe1f0f898..f6ee0f167de 100644 --- a/extensions/mistral/media-understanding-provider.ts +++ b/extensions/mistral/media-understanding-provider.ts @@ -1,5 +1,7 @@ -import { transcribeOpenAiCompatibleAudio } from "../../src/media-understanding/providers/openai-compatible-audio.js"; -import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; +import { + transcribeOpenAiCompatibleAudio, + type MediaUnderstandingProvider, +} from "openclaw/plugin-sdk/media-understanding"; const DEFAULT_MISTRAL_AUDIO_BASE_URL = "https://api.mistral.ai/v1"; const DEFAULT_MISTRAL_AUDIO_MODEL = "voxtral-mini-latest"; diff --git a/extensions/mistral/model-definitions.ts b/extensions/mistral/model-definitions.ts index 90d3c84c73d..2e915da172a 100644 --- a/extensions/mistral/model-definitions.ts +++ b/extensions/mistral/model-definitions.ts @@ -1,4 +1,4 @@ -import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; +import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-models"; export const MISTRAL_BASE_URL = "https://api.mistral.ai/v1"; export const MISTRAL_DEFAULT_MODEL_ID = "mistral-large-latest"; diff --git a/extensions/modelstudio/model-definitions.ts b/extensions/modelstudio/model-definitions.ts index 765e3962329..16fcdc6ec8c 100644 --- a/extensions/modelstudio/model-definitions.ts +++ b/extensions/modelstudio/model-definitions.ts @@ -1,4 +1,4 @@ -import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; +import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-models"; export const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; export const MODELSTUDIO_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; diff --git a/extensions/moonshot/media-understanding-provider.ts b/extensions/moonshot/media-understanding-provider.ts index 52bc9701c26..5814ee96e22 100644 --- a/extensions/moonshot/media-understanding-provider.ts +++ b/extensions/moonshot/media-understanding-provider.ts @@ -1,14 +1,12 @@ -import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; import { assertOkOrThrowHttpError, + describeImageWithModel, normalizeBaseUrl, postJsonRequest, -} from "../../src/media-understanding/providers/shared.js"; -import type { - MediaUnderstandingProvider, - VideoDescriptionRequest, - VideoDescriptionResult, -} from "../../src/media-understanding/types.js"; + type MediaUnderstandingProvider, + type VideoDescriptionRequest, + type VideoDescriptionResult, +} from "openclaw/plugin-sdk/media-understanding"; export const DEFAULT_MOONSHOT_VIDEO_BASE_URL = "https://api.moonshot.ai/v1"; const DEFAULT_MOONSHOT_VIDEO_MODEL = "kimi-k2.5"; diff --git a/extensions/nvidia/index.ts b/extensions/nvidia/index.ts index 82b59e40a93..583932bc600 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 { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { buildNvidiaProvider } from "./provider-catalog.js"; const PROVIDER_ID = "nvidia"; diff --git a/extensions/openai/media-understanding-provider.ts b/extensions/openai/media-understanding-provider.ts index c97f317bf4d..dcb0a731a91 100644 --- a/extensions/openai/media-understanding-provider.ts +++ b/extensions/openai/media-understanding-provider.ts @@ -1,13 +1,14 @@ -import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; -import { transcribeOpenAiCompatibleAudio } from "../../src/media-understanding/providers/openai-compatible-audio.js"; -import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; +import { + describeImageWithModel, + transcribeOpenAiCompatibleAudio, + type AudioTranscriptionRequest, + type MediaUnderstandingProvider, +} from "openclaw/plugin-sdk/media-understanding"; export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1"; const DEFAULT_OPENAI_AUDIO_MODEL = "gpt-4o-mini-transcribe"; -export async function transcribeOpenAiAudio( - params: import("../../src/media-understanding/types.js").AudioTranscriptionRequest, -) { +export async function transcribeOpenAiAudio(params: AudioTranscriptionRequest) { return await transcribeOpenAiCompatibleAudio({ ...params, defaultBaseUrl: DEFAULT_OPENAI_AUDIO_BASE_URL, diff --git a/extensions/qianfan/index.ts b/extensions/qianfan/index.ts index 42b5b8a0cb7..04094e1c2ca 100644 --- a/extensions/qianfan/index.ts +++ b/extensions/qianfan/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; -import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildQianfanProvider } from "./provider-catalog.js"; diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index e7276da9ae1..d818eaab196 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -1,18 +1,20 @@ +import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; +import { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { + formatDocsLink, + hasConfiguredSecretInput, + patchChannelConfigForAccount, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import { buildChannelConfigSchema, getChatChannelMeta, SlackConfigSchema, type ChannelPlugin, } from "openclaw/plugin-sdk/slack"; -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, diff --git a/extensions/synthetic/index.ts b/extensions/synthetic/index.ts index f538dd1fbcb..9bdeea0b8a5 100644 --- a/extensions/synthetic/index.ts +++ b/extensions/synthetic/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; -import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applySyntheticConfig, SYNTHETIC_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildSyntheticProvider } from "./provider-catalog.js"; diff --git a/extensions/tlon/src/setup-core.ts b/extensions/tlon/src/setup-core.ts index 08d72f2ab28..846af4f08a3 100644 --- a/extensions/tlon/src/setup-core.ts +++ b/extensions/tlon/src/setup-core.ts @@ -1,11 +1,12 @@ import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, patchScopedAccountConfig, prepareScopedSetupConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + type ChannelSetupAdapter, + type ChannelSetupInput, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import { buildTlonAccountFields } from "./account-fields.js"; import { resolveTlonAccount } from "./types.js"; diff --git a/extensions/together/index.ts b/extensions/together/index.ts index 2ae0072ca88..01bf59338f1 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; -import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyTogetherConfig, TOGETHER_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildTogetherProvider } from "./provider-catalog.js"; diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index b67831fe7a9..37d4e767db3 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; -import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildVeniceProvider } from "./provider-catalog.js"; diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index 433f6cee09a..fc4dbae156a 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -1,6 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; -import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildVercelAiGatewayProvider } from "./provider-catalog.js"; diff --git a/extensions/whatsapp/src/channel.runtime.ts b/extensions/whatsapp/src/channel.runtime.ts index 46dd5f987d2..1273da7bbd0 100644 --- a/extensions/whatsapp/src/channel.runtime.ts +++ b/extensions/whatsapp/src/channel.runtime.ts @@ -9,4 +9,4 @@ export { export { loginWeb } from "./login.js"; export { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js"; export { whatsappSetupWizard } from "./setup-surface.js"; -export { monitorWebChannel } from "../../../src/channels/web/index.js"; +export { monitorWebChannel } from "openclaw/plugin-sdk/whatsapp"; diff --git a/extensions/xai/model-definitions.ts b/extensions/xai/model-definitions.ts index 5d3383eff8e..ff3a892500e 100644 --- a/extensions/xai/model-definitions.ts +++ b/extensions/xai/model-definitions.ts @@ -1,4 +1,4 @@ -import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; +import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-models"; export const XAI_BASE_URL = "https://api.x.ai/v1"; export const XAI_DEFAULT_MODEL_ID = "grok-4"; diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index 2edc1b33b25..dd18127edfa 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -1,7 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { PROVIDER_LABELS } from "openclaw/plugin-sdk/provider-usage"; -import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { applyXiaomiConfig, XIAOMI_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildXiaomiProvider } from "./provider-catalog.js"; diff --git a/extensions/zai/media-understanding-provider.ts b/extensions/zai/media-understanding-provider.ts index bbd8bcc59fc..08f8c186d4d 100644 --- a/extensions/zai/media-understanding-provider.ts +++ b/extensions/zai/media-understanding-provider.ts @@ -1,5 +1,7 @@ -import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; -import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; +import { + describeImageWithModel, + type MediaUnderstandingProvider, +} from "openclaw/plugin-sdk/media-understanding"; export const zaiMediaUnderstandingProvider: MediaUnderstandingProvider = { id: "zai", diff --git a/extensions/zai/model-definitions.ts b/extensions/zai/model-definitions.ts index 2527ee53031..778d7602f73 100644 --- a/extensions/zai/model-definitions.ts +++ b/extensions/zai/model-definitions.ts @@ -1,4 +1,4 @@ -import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; +import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-models"; export const ZAI_CODING_GLOBAL_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; export const ZAI_CODING_CN_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts index 3e54c5a86dc..218ff32cf19 100644 --- a/extensions/zalo/src/setup-core.ts +++ b/extensions/zalo/src/setup-core.ts @@ -1,5 +1,4 @@ -import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { createPatchedAccountSetupAdapter, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; const channel = "zalo" as const; diff --git a/extensions/zalouser/src/setup-core.ts b/extensions/zalouser/src/setup-core.ts index f3215a16469..e1f9e9fd27c 100644 --- a/extensions/zalouser/src/setup-core.ts +++ b/extensions/zalouser/src/setup-core.ts @@ -1,4 +1,4 @@ -import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; +import { createPatchedAccountSetupAdapter } from "openclaw/plugin-sdk/setup"; const channel = "zalouser" as const; diff --git a/package.json b/package.json index 456603ea22c..afbcb632ed0 100644 --- a/package.json +++ b/package.json @@ -338,6 +338,10 @@ "types": "./dist/plugin-sdk/provider-auth.d.ts", "default": "./dist/plugin-sdk/provider-auth.js" }, + "./plugin-sdk/provider-catalog": { + "types": "./dist/plugin-sdk/provider-catalog.d.ts", + "default": "./dist/plugin-sdk/provider-catalog.js" + }, "./plugin-sdk/provider-models": { "types": "./dist/plugin-sdk/provider-models.d.ts", "default": "./dist/plugin-sdk/provider-models.js" @@ -358,6 +362,14 @@ "types": "./dist/plugin-sdk/provider-web-search.d.ts", "default": "./dist/plugin-sdk/provider-web-search.js" }, + "./plugin-sdk/media-understanding": { + "types": "./dist/plugin-sdk/media-understanding.d.ts", + "default": "./dist/plugin-sdk/media-understanding.js" + }, + "./plugin-sdk/google": { + "types": "./dist/plugin-sdk/google.d.ts", + "default": "./dist/plugin-sdk/google.js" + }, "./plugin-sdk/request-url": { "types": "./dist/plugin-sdk/request-url.d.ts", "default": "./dist/plugin-sdk/request-url.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index e2de1d74f1f..50813e8dd66 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -74,11 +74,14 @@ "json-store", "keyed-async-queue", "provider-auth", + "provider-catalog", "provider-models", "provider-onboard", "provider-stream", "provider-usage", "provider-web-search", + "media-understanding", + "google", "request-url", "runtime-store", "speech", diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index e9089cbfdcc..11283394ec8 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -1,5 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; +import { resolveSlackAccount } from "../../plugin-sdk/account-resolution.js"; import { deleteSlackMessage, downloadSlackFile, @@ -20,7 +21,6 @@ import { parseSlackBlocksInput, parseSlackTarget, recordSlackThreadParticipation, - resolveSlackAccount, resolveSlackChannelId, } from "../../plugin-sdk/slack.js"; import { withNormalizedTimestamp } from "../date-time.js"; diff --git a/src/channels/plugins/actions/signal.ts b/src/channels/plugins/actions/signal.ts index 60a70bac4c0..2eacd78857c 100644 --- a/src/channels/plugins/actions/signal.ts +++ b/src/channels/plugins/actions/signal.ts @@ -1,8 +1,8 @@ import { createActionGate, jsonResult, readStringParam } from "../../../agents/tools/common.js"; +import { resolveSignalAccount } from "../../../plugin-sdk/account-resolution.js"; import { listEnabledSignalAccounts, removeReactionSignal, - resolveSignalAccount, resolveSignalReactionLevel, sendReactionSignal, } from "../../../plugin-sdk/signal.js"; diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index a1cbf5fa6d9..912869f390b 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -22,13 +22,13 @@ import { normalizeTrustedSafeBinDirs, } from "../infra/exec-safe-bin-trust.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; +import { resolveTelegramAccount } from "../plugin-sdk/account-resolution.js"; import { fetchTelegramChatId, inspectTelegramAccount, isNumericTelegramUserId, listTelegramAccountIds, normalizeTelegramAllowFromEntry, - resolveTelegramAccount, } from "../plugin-sdk/telegram.js"; import { formatChannelAccountsDefaultPath, diff --git a/src/plugin-sdk/account-resolution.ts b/src/plugin-sdk/account-resolution.ts index cb819f57354..533d88187d0 100644 --- a/src/plugin-sdk/account-resolution.ts +++ b/src/plugin-sdk/account-resolution.ts @@ -10,6 +10,22 @@ export { normalizeOptionalAccountId, } from "../routing/session-key.js"; export { normalizeE164, pathExists, resolveUserPath } from "../utils.js"; +export { + resolveDiscordAccount, + type ResolvedDiscordAccount, +} from "../../extensions/discord/src/accounts.js"; +export { + resolveSlackAccount, + type ResolvedSlackAccount, +} from "../../extensions/slack/src/accounts.js"; +export { + resolveTelegramAccount, + type ResolvedTelegramAccount, +} from "../../extensions/telegram/src/accounts.js"; +export { + resolveSignalAccount, + type ResolvedSignalAccount, +} from "../../extensions/signal/src/accounts.js"; /** Resolve an account by id, then fall back to the default account when the primary lacks credentials. */ export function resolveAccountWithDefaultFallback(params: { diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index b31c796e2d6..273df91e908 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -61,7 +61,6 @@ export { createDiscordActionGate, listDiscordAccountIds, resolveDefaultDiscordAccountId, - resolveDiscordAccount, } from "../../extensions/discord/src/accounts.js"; export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export { diff --git a/src/plugin-sdk/google.ts b/src/plugin-sdk/google.ts new file mode 100644 index 00000000000..b39d4aa4ced --- /dev/null +++ b/src/plugin-sdk/google.ts @@ -0,0 +1,4 @@ +// Public Google-specific helpers used by bundled Google plugins. + +export { normalizeGoogleModelId } from "../agents/model-id-normalization.js"; +export { parseGeminiAuth } from "../infra/gemini-auth.js"; diff --git a/src/plugin-sdk/media-understanding.ts b/src/plugin-sdk/media-understanding.ts new file mode 100644 index 00000000000..052736afc3d --- /dev/null +++ b/src/plugin-sdk/media-understanding.ts @@ -0,0 +1,21 @@ +// Public media-understanding helpers and types for provider plugins. + +export type { + AudioTranscriptionRequest, + AudioTranscriptionResult, + ImageDescriptionRequest, + ImageDescriptionResult, + MediaUnderstandingProvider, + VideoDescriptionRequest, + VideoDescriptionResult, +} from "../media-understanding/types.js"; + +export { describeImageWithModel } from "../media-understanding/providers/image.js"; +export { transcribeOpenAiCompatibleAudio } from "../media-understanding/providers/openai-compatible-audio.js"; +export { + assertOkOrThrowHttpError, + normalizeBaseUrl, + postJsonRequest, + postTranscriptionRequest, + requireTranscriptionText, +} from "../media-understanding/providers/shared.js"; diff --git a/src/plugin-sdk/provider-catalog.ts b/src/plugin-sdk/provider-catalog.ts new file mode 100644 index 00000000000..7295658a3cb --- /dev/null +++ b/src/plugin-sdk/provider-catalog.ts @@ -0,0 +1,9 @@ +// Public provider catalog helpers for provider plugins. + +export type { ProviderCatalogContext, ProviderCatalogResult } from "../plugins/types.js"; + +export { + buildPairedProviderApiKeyCatalog, + buildSingleProviderApiKeyCatalog, + findCatalogTemplate, +} from "../plugins/provider-catalog.js"; diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts index a2a7cf5c302..b890045a5f8 100644 --- a/src/plugin-sdk/setup.ts +++ b/src/plugin-sdk/setup.ts @@ -21,8 +21,10 @@ export { normalizeE164, pathExists } from "../utils.js"; export { applyAccountNameToChannelSection, applySetupAccountConfigPatch, + createPatchedAccountSetupAdapter, migrateBaseNameToDefaultAccount, patchScopedAccountConfig, + prepareScopedSetupConfig, } from "../channels/plugins/setup-helpers.js"; export { addWildcardAllowFrom, diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index f7d3ec2d84d..da3d839e356 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -47,7 +47,6 @@ export { listEnabledSignalAccounts, listSignalAccountIds, resolveDefaultSignalAccountId, - resolveSignalAccount, } from "../../extensions/signal/src/accounts.js"; export { resolveSignalReactionLevel } from "../../extensions/signal/src/reaction-level.js"; export { diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index b883aebac95..8e6793543af 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -50,7 +50,6 @@ export { listEnabledSlackAccounts, listSlackAccountIds, resolveDefaultSlackAccountId, - resolveSlackAccount, resolveSlackReplyToMode, } from "../../extensions/slack/src/accounts.js"; export { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/src/interactive-replies.js"; diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index cb26a82cb13..db53fa92a35 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -65,7 +65,6 @@ export { listTelegramAccountIds, resolveDefaultTelegramAccountId, resolveTelegramPollActionGateState, - resolveTelegramAccount, } from "../../extensions/telegram/src/accounts.js"; export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export { From 87b9a063ce86d914f387830aa99ba356137f236d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:33:54 -0700 Subject: [PATCH 093/128] refactor: add shared provider model definitions --- src/plugins/provider-model-definitions.ts | 140 ++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 src/plugins/provider-model-definitions.ts diff --git a/src/plugins/provider-model-definitions.ts b/src/plugins/provider-model-definitions.ts new file mode 100644 index 00000000000..5788d0ad2ca --- /dev/null +++ b/src/plugins/provider-model-definitions.ts @@ -0,0 +1,140 @@ +import { KIMI_CODING_MODEL_REF } from "../../extensions/kimi-coding/onboard.js"; +import { + KIMI_DEFAULT_MODEL_ID as KIMI_CODING_MODEL_ID, + KIMI_CODING_BASE_URL, +} from "../../extensions/kimi-coding/provider-catalog.js"; +import { + DEFAULT_MINIMAX_BASE_URL, + MINIMAX_API_BASE_URL, + MINIMAX_API_COST, + MINIMAX_CN_API_BASE_URL, + MINIMAX_HOSTED_COST, + MINIMAX_HOSTED_MODEL_ID, + MINIMAX_HOSTED_MODEL_REF, + MINIMAX_LM_STUDIO_COST, + buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, +} from "../../extensions/minimax/model-definitions.js"; +import { + buildMistralModelDefinition, + MISTRAL_BASE_URL, + MISTRAL_DEFAULT_COST, + MISTRAL_DEFAULT_MODEL_ID, + MISTRAL_DEFAULT_MODEL_REF, +} from "../../extensions/mistral/model-definitions.js"; +import { + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_COST, + MODELSTUDIO_DEFAULT_MODEL_ID, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, + buildModelStudioDefaultModelDefinition, + buildModelStudioModelDefinition, +} from "../../extensions/modelstudio/model-definitions.js"; +import { + MOONSHOT_CN_BASE_URL, + MOONSHOT_DEFAULT_MODEL_REF, +} from "../../extensions/moonshot/onboard.js"; +import { + buildMoonshotProvider, + MOONSHOT_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, +} from "../../extensions/moonshot/provider-catalog.js"; +import { QIANFAN_DEFAULT_MODEL_REF } from "../../extensions/qianfan/onboard.js"; +import { + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, +} from "../../extensions/qianfan/provider-catalog.js"; +import { + XAI_BASE_URL, + XAI_DEFAULT_COST, + XAI_DEFAULT_MODEL_ID, + XAI_DEFAULT_MODEL_REF, + buildXaiModelDefinition, +} from "../../extensions/xai/model-definitions.js"; +import { + buildZaiModelDefinition, + resolveZaiBaseUrl, + ZAI_CN_BASE_URL, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_GLOBAL_BASE_URL, + ZAI_DEFAULT_COST, + ZAI_DEFAULT_MODEL_ID, + ZAI_GLOBAL_BASE_URL, +} from "../../extensions/zai/model-definitions.js"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import { + KILOCODE_DEFAULT_CONTEXT_WINDOW, + KILOCODE_DEFAULT_COST, + KILOCODE_DEFAULT_MAX_TOKENS, + KILOCODE_DEFAULT_MODEL_ID, + KILOCODE_DEFAULT_MODEL_NAME, +} from "../providers/kilocode-shared.js"; + +export { + DEFAULT_MINIMAX_BASE_URL, + MINIMAX_API_BASE_URL, + MINIMAX_API_COST, + MINIMAX_CN_API_BASE_URL, + MINIMAX_HOSTED_COST, + MINIMAX_HOSTED_MODEL_ID, + MINIMAX_HOSTED_MODEL_REF, + MINIMAX_LM_STUDIO_COST, + MISTRAL_BASE_URL, + MISTRAL_DEFAULT_COST, + MISTRAL_DEFAULT_MODEL_ID, + MISTRAL_DEFAULT_MODEL_REF, + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_COST, + MODELSTUDIO_DEFAULT_MODEL_ID, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, + MOONSHOT_BASE_URL, + MOONSHOT_CN_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, + MOONSHOT_DEFAULT_MODEL_REF, + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, + QIANFAN_DEFAULT_MODEL_REF, + XAI_BASE_URL, + XAI_DEFAULT_COST, + XAI_DEFAULT_MODEL_ID, + XAI_DEFAULT_MODEL_REF, + ZAI_CN_BASE_URL, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_GLOBAL_BASE_URL, + ZAI_DEFAULT_COST, + ZAI_DEFAULT_MODEL_ID, + ZAI_GLOBAL_BASE_URL, + KIMI_CODING_BASE_URL, + KIMI_CODING_MODEL_ID, + KIMI_CODING_MODEL_REF, + KILOCODE_DEFAULT_CONTEXT_WINDOW, + KILOCODE_DEFAULT_COST, + KILOCODE_DEFAULT_MAX_TOKENS, + KILOCODE_DEFAULT_MODEL_ID, + buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, + buildMistralModelDefinition, + buildModelStudioDefaultModelDefinition, + buildModelStudioModelDefinition, + buildXaiModelDefinition, + buildZaiModelDefinition, + resolveZaiBaseUrl, +}; + +export function buildMoonshotModelDefinition(): ModelDefinitionConfig { + return buildMoonshotProvider().models[0]; +} + +export function buildKilocodeModelDefinition(): ModelDefinitionConfig { + return { + id: KILOCODE_DEFAULT_MODEL_ID, + name: KILOCODE_DEFAULT_MODEL_NAME, + reasoning: true, + input: ["text", "image"], + cost: KILOCODE_DEFAULT_COST, + contextWindow: KILOCODE_DEFAULT_CONTEXT_WINDOW, + maxTokens: KILOCODE_DEFAULT_MAX_TOKENS, + }; +} From 5572e6965a7626cef22de211b7ab6e75bc93490b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 21:36:39 -0700 Subject: [PATCH 094/128] Agents: add provider attribution registry (#48735) * Agents: add provider attribution registry * Agents: record provider attribution matrix * Agents: align OpenRouter attribution headers --- .../pi-embedded-runner-extraparams.test.ts | 3 +- src/agents/pi-embedded-runner/extra-params.ts | 2 +- .../proxy-stream-wrappers.test.ts | 38 +++++ .../proxy-stream-wrappers.ts | 9 +- src/agents/provider-attribution.test.ts | 87 +++++++++++ src/agents/provider-attribution.ts | 138 ++++++++++++++++++ 6 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts create mode 100644 src/agents/provider-attribution.test.ts create mode 100644 src/agents/provider-attribution.ts diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 25395ea4827..9b22c59b594 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -1160,7 +1160,8 @@ describe("applyExtraParamsToAgent", () => { expect(calls).toHaveLength(1); expect(calls[0]?.headers).toEqual({ "HTTP-Referer": "https://openclaw.ai", - "X-Title": "OpenClaw", + "X-OpenRouter-Title": "OpenClaw", + "X-OpenRouter-Categories": "cli-agent", "X-Custom": "1", }); }); diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 713b193d7e7..7a73280802c 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -264,7 +264,7 @@ function createParallelToolCallsWrapper( /** * Apply extra params (like temperature) to an agent's streamFn. - * Also adds OpenRouter app attribution headers when using the OpenRouter provider. + * Also applies verified provider-specific request wrappers, such as OpenRouter attribution. * * @internal Exported for testing */ diff --git a/src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts b/src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts new file mode 100644 index 00000000000..487d90582ef --- /dev/null +++ b/src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts @@ -0,0 +1,38 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import type { Context, Model } from "@mariozechner/pi-ai"; +import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; +import { describe, expect, it } from "vitest"; +import { createOpenRouterWrapper } from "./proxy-stream-wrappers.js"; + +describe("proxy stream wrappers", () => { + it("adds OpenRouter attribution headers to stream options", () => { + const calls: Array<{ headers?: Record }> = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + calls.push({ + headers: options?.headers, + }); + return createAssistantMessageEventStream(); + }; + + const wrapped = createOpenRouterWrapper(baseStreamFn); + const model = { + api: "openai-completions", + provider: "openrouter", + id: "openrouter/auto", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + + void wrapped(model, context, { headers: { "X-Custom": "1" } }); + + expect(calls).toEqual([ + { + headers: { + "HTTP-Referer": "https://openclaw.ai", + "X-OpenRouter-Title": "OpenClaw", + "X-OpenRouter-Categories": "cli-agent", + "X-Custom": "1", + }, + }, + ]); + }); +}); diff --git a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts b/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts index 4f77c31cfdd..cc5e7596050 100644 --- a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts @@ -1,11 +1,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { streamSimple } from "@mariozechner/pi-ai"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; - -const OPENROUTER_APP_HEADERS: Record = { - "HTTP-Referer": "https://openclaw.ai", - "X-Title": "OpenClaw", -}; +import { resolveProviderAttributionHeaders } from "../provider-attribution.js"; const KILOCODE_FEATURE_HEADER = "X-KILOCODE-FEATURE"; const KILOCODE_FEATURE_DEFAULT = "openclaw"; const KILOCODE_FEATURE_ENV_VAR = "KILOCODE_FEATURE"; @@ -105,10 +101,11 @@ export function createOpenRouterWrapper( const underlying = baseStreamFn ?? streamSimple; return (model, context, options) => { const onPayload = options?.onPayload; + const attributionHeaders = resolveProviderAttributionHeaders("openrouter"); return underlying(model, context, { ...options, headers: { - ...OPENROUTER_APP_HEADERS, + ...attributionHeaders, ...options?.headers, }, onPayload: (payload) => { diff --git a/src/agents/provider-attribution.test.ts b/src/agents/provider-attribution.test.ts new file mode 100644 index 00000000000..693e165ba21 --- /dev/null +++ b/src/agents/provider-attribution.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + listProviderAttributionPolicies, + resolveProviderAttributionHeaders, + resolveProviderAttributionIdentity, + resolveProviderAttributionPolicy, +} from "./provider-attribution.js"; + +describe("provider attribution", () => { + it("resolves the canonical OpenClaw product and runtime version", () => { + const identity = resolveProviderAttributionIdentity({ + OPENCLAW_VERSION: "2026.3.99", + }); + + expect(identity).toEqual({ + product: "OpenClaw", + version: "2026.3.99", + }); + }); + + it("returns a documented OpenRouter attribution policy", () => { + const policy = resolveProviderAttributionPolicy("openrouter", { + OPENCLAW_VERSION: "2026.3.14", + }); + + expect(policy).toEqual({ + provider: "openrouter", + enabledByDefault: true, + verification: "vendor-documented", + hook: "request-headers", + docsUrl: "https://openrouter.ai/docs/app-attribution", + reviewNote: "Documented app attribution headers. Verified in OpenClaw runtime wrapper.", + product: "OpenClaw", + version: "2026.3.14", + headers: { + "HTTP-Referer": "https://openclaw.ai", + "X-OpenRouter-Title": "OpenClaw", + "X-OpenRouter-Categories": "cli-agent", + }, + }); + }); + + it("normalizes aliases when resolving provider headers", () => { + expect( + resolveProviderAttributionHeaders("OpenRouter", { + OPENCLAW_VERSION: "2026.3.14", + }), + ).toEqual({ + "HTTP-Referer": "https://openclaw.ai", + "X-OpenRouter-Title": "OpenClaw", + "X-OpenRouter-Categories": "cli-agent", + }); + }); + + it("tracks SDK-hook-only providers without enabling them", () => { + expect(resolveProviderAttributionPolicy("openai", { OPENCLAW_VERSION: "2026.3.14" })).toEqual({ + provider: "openai", + enabledByDefault: false, + verification: "vendor-sdk-hook-only", + hook: "default-headers", + reviewNote: + "OpenAI JS SDK exposes defaultHeaders, but public app attribution support is not yet verified.", + product: "OpenClaw", + version: "2026.3.14", + }); + expect(resolveProviderAttributionHeaders("openai")).toBeUndefined(); + }); + + it("lists the current attribution support matrix", () => { + expect( + listProviderAttributionPolicies({ OPENCLAW_VERSION: "2026.3.14" }).map((policy) => [ + policy.provider, + policy.enabledByDefault, + policy.verification, + policy.hook, + ]), + ).toEqual([ + ["openrouter", true, "vendor-documented", "request-headers"], + ["anthropic", false, "vendor-sdk-hook-only", "default-headers"], + ["google", false, "vendor-sdk-hook-only", "user-agent-extra"], + ["groq", false, "vendor-sdk-hook-only", "default-headers"], + ["mistral", false, "vendor-sdk-hook-only", "custom-user-agent"], + ["openai", false, "vendor-sdk-hook-only", "default-headers"], + ["together", false, "vendor-sdk-hook-only", "default-headers"], + ]); + }); +}); diff --git a/src/agents/provider-attribution.ts b/src/agents/provider-attribution.ts new file mode 100644 index 00000000000..52fe5c8d4c7 --- /dev/null +++ b/src/agents/provider-attribution.ts @@ -0,0 +1,138 @@ +import type { RuntimeVersionEnv } from "../version.js"; +import { resolveRuntimeServiceVersion } from "../version.js"; +import { normalizeProviderId } from "./model-selection.js"; + +export type ProviderAttributionVerification = + | "vendor-documented" + | "vendor-sdk-hook-only" + | "internal-runtime"; + +export type ProviderAttributionHook = + | "request-headers" + | "default-headers" + | "user-agent-extra" + | "custom-user-agent"; + +export type ProviderAttributionPolicy = { + provider: string; + enabledByDefault: boolean; + verification: ProviderAttributionVerification; + hook?: ProviderAttributionHook; + docsUrl?: string; + reviewNote?: string; + product: string; + version: string; + headers?: Record; +}; + +export type ProviderAttributionIdentity = Pick; + +const OPENCLAW_ATTRIBUTION_PRODUCT = "OpenClaw"; + +export function resolveProviderAttributionIdentity( + env: RuntimeVersionEnv = process.env as RuntimeVersionEnv, +): ProviderAttributionIdentity { + return { + product: OPENCLAW_ATTRIBUTION_PRODUCT, + version: resolveRuntimeServiceVersion(env), + }; +} + +function buildOpenRouterAttributionPolicy( + env: RuntimeVersionEnv = process.env as RuntimeVersionEnv, +): ProviderAttributionPolicy { + const identity = resolveProviderAttributionIdentity(env); + return { + provider: "openrouter", + enabledByDefault: true, + verification: "vendor-documented", + hook: "request-headers", + docsUrl: "https://openrouter.ai/docs/app-attribution", + reviewNote: "Documented app attribution headers. Verified in OpenClaw runtime wrapper.", + ...identity, + headers: { + "HTTP-Referer": "https://openclaw.ai", + "X-OpenRouter-Title": identity.product, + "X-OpenRouter-Categories": "cli-agent", + }, + }; +} + +function buildSdkHookOnlyPolicy( + provider: string, + hook: ProviderAttributionHook, + reviewNote: string, + env: RuntimeVersionEnv = process.env as RuntimeVersionEnv, +): ProviderAttributionPolicy { + return { + provider, + enabledByDefault: false, + verification: "vendor-sdk-hook-only", + hook, + reviewNote, + ...resolveProviderAttributionIdentity(env), + }; +} + +export function listProviderAttributionPolicies( + env: RuntimeVersionEnv = process.env as RuntimeVersionEnv, +): ProviderAttributionPolicy[] { + return [ + buildOpenRouterAttributionPolicy(env), + buildSdkHookOnlyPolicy( + "anthropic", + "default-headers", + "Anthropic JS SDK exposes defaultHeaders, but app attribution is not yet verified.", + env, + ), + buildSdkHookOnlyPolicy( + "google", + "user-agent-extra", + "Google GenAI JS SDK exposes userAgentExtra/httpOptions, but provider-side attribution is not yet verified.", + env, + ), + buildSdkHookOnlyPolicy( + "groq", + "default-headers", + "Groq JS SDK exposes defaultHeaders, but app attribution is not yet verified.", + env, + ), + buildSdkHookOnlyPolicy( + "mistral", + "custom-user-agent", + "Mistral JS SDK exposes a custom userAgent option, but app attribution is not yet verified.", + env, + ), + buildSdkHookOnlyPolicy( + "openai", + "default-headers", + "OpenAI JS SDK exposes defaultHeaders, but public app attribution support is not yet verified.", + env, + ), + buildSdkHookOnlyPolicy( + "together", + "default-headers", + "Together JS SDK exposes defaultHeaders, but app attribution is not yet verified.", + env, + ), + ]; +} + +export function resolveProviderAttributionPolicy( + provider?: string | null, + env: RuntimeVersionEnv = process.env as RuntimeVersionEnv, +): ProviderAttributionPolicy | undefined { + const normalized = normalizeProviderId(provider ?? ""); + return listProviderAttributionPolicies(env).find((policy) => policy.provider === normalized); +} + +export function resolveProviderAttributionHeaders( + provider?: string | null, + env: RuntimeVersionEnv = process.env as RuntimeVersionEnv, +): Record | undefined { + const policy = resolveProviderAttributionPolicy(provider, env); + if (!policy?.enabledByDefault) { + return undefined; + } + return policy.headers; +} From 38bc364aedb7d875564a786905b7bc0f5db9fdaa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 21:36:22 -0700 Subject: [PATCH 095/128] Runtime: narrow WhatsApp login tool surface --- .../runtime/runtime-whatsapp-login-tool.ts | 71 +++++++++++++++++++ src/plugins/runtime/runtime-whatsapp.ts | 4 +- src/plugins/runtime/types-channel.ts | 2 +- 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 src/plugins/runtime/runtime-whatsapp-login-tool.ts diff --git a/src/plugins/runtime/runtime-whatsapp-login-tool.ts b/src/plugins/runtime/runtime-whatsapp-login-tool.ts new file mode 100644 index 00000000000..88b5d0e6138 --- /dev/null +++ b/src/plugins/runtime/runtime-whatsapp-login-tool.ts @@ -0,0 +1,71 @@ +import { Type } from "@sinclair/typebox"; +import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-runtime"; + +export function createRuntimeWhatsAppLoginTool(): ChannelAgentTool { + return { + label: "WhatsApp Login", + name: "whatsapp_login", + ownerOnly: true, + description: "Generate a WhatsApp QR code for linking, or wait for the scan to complete.", + parameters: Type.Object({ + action: Type.Unsafe<"start" | "wait">({ + type: "string", + enum: ["start", "wait"], + }), + timeoutMs: Type.Optional(Type.Number()), + force: Type.Optional(Type.Boolean()), + }), + execute: async (_toolCallId, args) => { + const { startWebLoginWithQr, waitForWebLogin } = + await import("../../../extensions/whatsapp/src/login-qr.js"); + const action = (args as { action?: string })?.action ?? "start"; + if (action === "wait") { + const result = await waitForWebLogin({ + timeoutMs: + typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" + ? (args as { timeoutMs?: number }).timeoutMs + : undefined, + }); + return { + content: [{ type: "text", text: result.message }], + details: { connected: result.connected }, + }; + } + + const result = await startWebLoginWithQr({ + timeoutMs: + typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" + ? (args as { timeoutMs?: number }).timeoutMs + : undefined, + force: + typeof (args as { force?: unknown }).force === "boolean" + ? (args as { force?: boolean }).force + : false, + }); + + if (!result.qrDataUrl) { + return { + content: [ + { + type: "text", + text: result.message, + }, + ], + details: { qr: false }, + }; + } + + const text = [ + result.message, + "", + "Open WhatsApp -> Linked Devices and scan:", + "", + `![whatsapp-qr](${result.qrDataUrl})`, + ].join("\n"); + return { + content: [{ type: "text", text }], + details: { qr: true }, + }; + }, + }; +} diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index 20d36a936f0..21a92aefe09 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -6,7 +6,7 @@ import { readWebSelfId, webAuthExists, } from "../../../extensions/whatsapp/src/auth-store.js"; -import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js"; +import { createRuntimeWhatsAppLoginTool } from "./runtime-whatsapp-login-tool.js"; import type { PluginRuntime } from "./types.js"; const sendMessageWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendMessageWhatsApp"] = async ( @@ -106,6 +106,6 @@ export function createRuntimeWhatsApp(): PluginRuntime["channel"]["whatsapp"] { waitForWebLogin: waitForWebLoginLazy, monitorWebChannel: monitorWebChannelLazy, handleWhatsAppAction: handleWhatsAppActionLazy, - createLoginTool: createWhatsAppLoginTool, + createLoginTool: createRuntimeWhatsAppLoginTool, }; } diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index f8e6e095ef5..1b0c21044a8 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -211,7 +211,7 @@ export type PluginRuntimeChannel = { waitForWebLogin: typeof import("../../../extensions/whatsapp/src/login-qr.js").waitForWebLogin; monitorWebChannel: typeof import("../../channels/web/index.js").monitorWebChannel; handleWhatsAppAction: typeof import("../../agents/tools/whatsapp-actions.js").handleWhatsAppAction; - createLoginTool: typeof import("../../channels/plugins/agent-tools/whatsapp-login.js").createWhatsAppLoginTool; + createLoginTool: typeof import("./runtime-whatsapp-login-tool.js").createRuntimeWhatsAppLoginTool; }; line: { listLineAccountIds: typeof import("../../line/accounts.js").listLineAccountIds; From 06459ca0dfba4ca152d2565b6b29efe9f8360b90 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 21:46:05 -0700 Subject: [PATCH 096/128] Agents: run bundle MCP tools in embedded Pi (#48611) * Agents: run bundle MCP tools in embedded Pi * Plugins: fix bundle MCP path resolution * Plugins: warn on unsupported bundle MCP transports * Commands: add embedded Pi MCP management * Config: move MCP management to top-level config --- CHANGELOG.md | 1 + docs/plugins/bundles.md | 20 +- docs/tools/plugin.md | 11 +- src/agents/embedded-pi-mcp.ts | 29 ++ src/agents/mcp-stdio.ts | 79 +++++ src/agents/pi-bundle-mcp-tools.test.ts | 184 +++++++++++ src/agents/pi-bundle-mcp-tools.ts | 225 +++++++++++++ .../pi-embedded-runner.bundle-mcp.e2e.test.ts | 302 ++++++++++++++++++ src/agents/pi-embedded-runner/compact.ts | 24 +- src/agents/pi-embedded-runner/run/attempt.ts | 26 +- src/agents/pi-project-settings.bundle.test.ts | 100 ++++++ src/agents/pi-project-settings.test.ts | 30 ++ src/agents/pi-project-settings.ts | 14 + src/auto-reply/commands-args.ts | 11 + src/auto-reply/commands-registry.data.ts | 28 ++ src/auto-reply/commands-registry.ts | 3 + src/auto-reply/reply/commands-core.ts | 2 + src/auto-reply/reply/commands-mcp.test.ts | 93 ++++++ src/auto-reply/reply/commands-mcp.ts | 134 ++++++++ src/auto-reply/reply/mcp-commands.ts | 24 ++ src/cli/mcp-cli.test.ts | 83 +++++ src/cli/mcp-cli.ts | 103 ++++++ src/cli/program/command-registry.ts | 13 + src/config/mcp-config.test.ts | 56 ++++ src/config/mcp-config.ts | 150 +++++++++ src/config/schema.help.ts | 5 + src/config/schema.labels.ts | 3 + src/config/types.mcp.ts | 14 + src/config/types.messages.ts | 2 + src/config/types.openclaw.ts | 2 + src/config/types.ts | 1 + src/config/zod-schema.session.ts | 1 + src/config/zod-schema.ts | 19 ++ src/plugins/bundle-mcp.test.ts | 65 ++++ src/plugins/bundle-mcp.ts | 82 ++++- src/plugins/loader.test.ts | 110 +++++++ src/plugins/loader.ts | 32 ++ 37 files changed, 2051 insertions(+), 30 deletions(-) create mode 100644 src/agents/embedded-pi-mcp.ts create mode 100644 src/agents/mcp-stdio.ts create mode 100644 src/agents/pi-bundle-mcp-tools.test.ts create mode 100644 src/agents/pi-bundle-mcp-tools.ts create mode 100644 src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts create mode 100644 src/auto-reply/reply/commands-mcp.test.ts create mode 100644 src/auto-reply/reply/commands-mcp.ts create mode 100644 src/auto-reply/reply/mcp-commands.ts create mode 100644 src/cli/mcp-cli.test.ts create mode 100644 src/cli/mcp-cli.ts create mode 100644 src/config/mcp-config.test.ts create mode 100644 src/config/mcp-config.ts create mode 100644 src/config/types.mcp.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 24335d41a91..4ff37ae11c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - 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. +- Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import. ### Breaking diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index 2fad626ccfe..bc6bc49e5a0 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -104,11 +104,15 @@ loader. Cursor command markdown works through the same path. - `HOOK.md` - `handler.ts` or `handler.js` -#### MCP for CLI backends +#### MCP for Pi - enabled bundles can contribute MCP server config -- current runtime wiring is used by the `claude-cli` backend -- OpenClaw merges bundle MCP config into the backend `--mcp-config` file +- OpenClaw merges bundle MCP config into the effective embedded Pi settings as + `mcpServers` +- OpenClaw also exposes supported bundle MCP tools during embedded Pi agent + turns by launching supported stdio MCP servers as subprocesses +- project-local Pi settings still apply after bundle defaults, so workspace + settings can override bundle MCP entries when needed #### Embedded Pi settings @@ -133,7 +137,6 @@ diagnostics/info output, but OpenClaw does not run them yet: - Cursor `.cursor/agents` - Cursor `.cursor/hooks.json` - Cursor `.cursor/rules` -- Cursor `mcpServers` outside the current mapped runtime paths - Codex inline/app metadata beyond capability reporting ## Capability reporting @@ -153,7 +156,8 @@ Current exceptions: - Claude `commands` is considered supported because it maps to skills - Claude `settings` is considered supported because it maps to embedded Pi settings - Cursor `commands` is considered supported because it maps to skills -- bundle MCP is considered supported where OpenClaw actually imports it +- bundle MCP is considered supported because it maps into embedded Pi settings + and exposes supported stdio tools to embedded Pi - Codex `hooks` is considered supported only for OpenClaw hook-pack layouts ## Format differences @@ -195,6 +199,8 @@ Claude-specific notes: - `commands/` is treated like skill content - `settings.json` is imported into embedded Pi settings +- `.mcp.json` and manifest `mcpServers` can expose supported stdio tools to + embedded Pi - `hooks/hooks.json` is detected, but not executed as Claude automation ### Cursor @@ -246,7 +252,9 @@ Current behavior: - bundle discovery reads files inside the plugin root with boundary checks - skills and hook-pack paths must stay inside the plugin root - bundle settings files are read with the same boundary checks -- OpenClaw does not execute arbitrary bundle runtime code in-process +- supported stdio bundle MCP servers may be launched as subprocesses for + embedded Pi tool calls +- OpenClaw does not load arbitrary bundle runtime modules in-process This makes bundle support safer by default than native plugin modules, but you should still treat third-party bundles as trusted content for the features they diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 8ab2ba87e1f..48acd41e202 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -214,18 +214,23 @@ plugins: OpenClaw skill loader - supported now: Claude bundle `settings.json` defaults for embedded Pi agent settings (with shell override keys sanitized) +- supported now: bundle MCP config, merged into embedded Pi agent settings as + `mcpServers`, with supported stdio bundle MCP tools exposed during embedded + Pi agent turns - supported now: Cursor `.cursor/commands/*.md` roots, mapped into the normal OpenClaw skill loader - supported now: Codex bundle hook directories that use the OpenClaw hook-pack layout (`HOOK.md` + `handler.ts`/`handler.js`) - detected but not wired yet: other declared bundle capabilities such as - agents, Claude hook automation, Cursor rules/hooks/MCP metadata, MCP/app/LSP + agents, Claude hook automation, Cursor rules/hooks metadata, app/LSP metadata, output styles That means bundle install/discovery/list/info/enablement all work, and bundle skills, Claude command-skills, Claude bundle settings defaults, and compatible -Codex hook directories load when the bundle is enabled, but bundle runtime code -is not executed in-process. +Codex hook directories load when the bundle is enabled. Supported bundle MCP +servers may also run as subprocesses for embedded Pi tool calls when they use +supported stdio transport, but bundle runtime modules are not loaded +in-process. Bundle hook support is limited to the normal OpenClaw hook directory format (`HOOK.md` plus `handler.ts`/`handler.js` under the declared hook roots). diff --git a/src/agents/embedded-pi-mcp.ts b/src/agents/embedded-pi-mcp.ts new file mode 100644 index 00000000000..82d4d0e486c --- /dev/null +++ b/src/agents/embedded-pi-mcp.ts @@ -0,0 +1,29 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { normalizeConfiguredMcpServers } from "../config/mcp-config.js"; +import type { BundleMcpDiagnostic, BundleMcpServerConfig } from "../plugins/bundle-mcp.js"; +import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js"; + +export type EmbeddedPiMcpConfig = { + mcpServers: Record; + diagnostics: BundleMcpDiagnostic[]; +}; + +export function loadEmbeddedPiMcpConfig(params: { + workspaceDir: string; + cfg?: OpenClawConfig; +}): EmbeddedPiMcpConfig { + const bundleMcp = loadEnabledBundleMcpConfig({ + workspaceDir: params.workspaceDir, + cfg: params.cfg, + }); + const configuredMcp = normalizeConfiguredMcpServers(params.cfg?.mcp?.servers); + + return { + // OpenClaw config is the owner-managed layer, so it overrides bundle defaults. + mcpServers: { + ...bundleMcp.config.mcpServers, + ...configuredMcp, + }, + diagnostics: bundleMcp.diagnostics, + }; +} diff --git a/src/agents/mcp-stdio.ts b/src/agents/mcp-stdio.ts new file mode 100644 index 00000000000..77ab6171ca7 --- /dev/null +++ b/src/agents/mcp-stdio.ts @@ -0,0 +1,79 @@ +type StdioMcpServerLaunchConfig = { + command: string; + args?: string[]; + env?: Record; + cwd?: string; +}; + +type StdioMcpServerLaunchResult = + | { ok: true; config: StdioMcpServerLaunchConfig } + | { ok: false; reason: string }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function toStringRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const entries = Object.entries(value) + .map(([key, entry]) => { + if (typeof entry === "string") { + return [key, entry] as const; + } + if (typeof entry === "number" || typeof entry === "boolean") { + return [key, String(entry)] as const; + } + return null; + }) + .filter((entry): entry is readonly [string, string] => entry !== null); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function toStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const entries = value.filter((entry): entry is string => typeof entry === "string"); + return entries.length > 0 ? entries : []; +} + +export function resolveStdioMcpServerLaunchConfig(raw: unknown): StdioMcpServerLaunchResult { + if (!isRecord(raw)) { + return { ok: false, reason: "server config must be an object" }; + } + if (typeof raw.command !== "string" || raw.command.trim().length === 0) { + if (typeof raw.url === "string" && raw.url.trim().length > 0) { + return { + ok: false, + reason: "only stdio MCP servers are supported right now", + }; + } + return { ok: false, reason: "its command is missing" }; + } + const cwd = + typeof raw.cwd === "string" && raw.cwd.trim().length > 0 + ? raw.cwd + : typeof raw.workingDirectory === "string" && raw.workingDirectory.trim().length > 0 + ? raw.workingDirectory + : undefined; + return { + ok: true, + config: { + command: raw.command, + args: toStringArray(raw.args), + env: toStringRecord(raw.env), + cwd, + }, + }; +} + +export function describeStdioMcpServerLaunchConfig(config: StdioMcpServerLaunchConfig): string { + const args = + Array.isArray(config.args) && config.args.length > 0 ? ` ${config.args.join(" ")}` : ""; + const cwd = config.cwd ? ` (cwd=${config.cwd})` : ""; + return `${config.command}${args}${cwd}`; +} + +export type { StdioMcpServerLaunchConfig, StdioMcpServerLaunchResult }; diff --git a/src/agents/pi-bundle-mcp-tools.test.ts b/src/agents/pi-bundle-mcp-tools.test.ts new file mode 100644 index 00000000000..69b2839eb94 --- /dev/null +++ b/src/agents/pi-bundle-mcp-tools.test.ts @@ -0,0 +1,184 @@ +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createBundleMcpToolRuntime } from "./pi-bundle-mcp-tools.js"; + +const require = createRequire(import.meta.url); +const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js"); +const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js"); + +const tempDirs: string[] = []; + +async function makeTempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function writeExecutable(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 }); +} + +async function writeBundleProbeMcpServer(filePath: string): Promise { + await writeExecutable( + filePath, + `#!/usr/bin/env node +import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)}; +import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)}; + +const server = new McpServer({ name: "bundle-probe", version: "1.0.0" }); +server.tool("bundle_probe", "Bundle MCP probe", async () => { + return { + content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }], + }; +}); + +await server.connect(new StdioServerTransport()); +`, + ); +} + +async function writeClaudeBundle(params: { + pluginRoot: string; + serverScriptPath: string; +}): Promise { + await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true }); + await fs.writeFile( + path.join(params.pluginRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + path.join(params.pluginRoot, ".mcp.json"), + `${JSON.stringify( + { + mcpServers: { + bundleProbe: { + command: "node", + args: [path.relative(params.pluginRoot, params.serverScriptPath)], + env: { + BUNDLE_PROBE_TEXT: "FROM-BUNDLE", + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); +} + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +describe("createBundleMcpToolRuntime", () => { + it("loads bundle MCP tools and executes them", async () => { + const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-"); + const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe"); + const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs"); + await writeBundleProbeMcpServer(serverScriptPath); + await writeClaudeBundle({ pluginRoot, serverScriptPath }); + + const runtime = await createBundleMcpToolRuntime({ + workspaceDir, + cfg: { + plugins: { + entries: { + "bundle-probe": { enabled: true }, + }, + }, + }, + }); + + try { + expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundle_probe"]); + const result = await runtime.tools[0].execute("call-bundle-probe", {}, undefined, undefined); + expect(result.content[0]).toMatchObject({ + type: "text", + text: "FROM-BUNDLE", + }); + expect(result.details).toEqual({ + mcpServer: "bundleProbe", + mcpTool: "bundle_probe", + }); + } finally { + await runtime.dispose(); + } + }); + + it("skips bundle MCP tools that collide with existing tool names", async () => { + const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-"); + const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe"); + const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs"); + await writeBundleProbeMcpServer(serverScriptPath); + await writeClaudeBundle({ pluginRoot, serverScriptPath }); + + const runtime = await createBundleMcpToolRuntime({ + workspaceDir, + cfg: { + plugins: { + entries: { + "bundle-probe": { enabled: true }, + }, + }, + }, + reservedToolNames: ["bundle_probe"], + }); + + try { + expect(runtime.tools).toEqual([]); + } finally { + await runtime.dispose(); + } + }); + + it("loads configured stdio MCP tools without a bundle", async () => { + const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-"); + const serverScriptPath = path.join(workspaceDir, "servers", "configured-probe.mjs"); + await writeBundleProbeMcpServer(serverScriptPath); + + const runtime = await createBundleMcpToolRuntime({ + workspaceDir, + cfg: { + mcp: { + servers: { + configuredProbe: { + command: "node", + args: [serverScriptPath], + env: { + BUNDLE_PROBE_TEXT: "FROM-CONFIG", + }, + }, + }, + }, + }, + }); + + try { + expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundle_probe"]); + const result = await runtime.tools[0].execute( + "call-configured-probe", + {}, + undefined, + undefined, + ); + expect(result.content[0]).toMatchObject({ + type: "text", + text: "FROM-CONFIG", + }); + expect(result.details).toEqual({ + mcpServer: "configuredProbe", + mcpTool: "bundle_probe", + }); + } finally { + await runtime.dispose(); + } + }); +}); diff --git a/src/agents/pi-bundle-mcp-tools.ts b/src/agents/pi-bundle-mcp-tools.ts new file mode 100644 index 00000000000..159cd8bfe12 --- /dev/null +++ b/src/agents/pi-bundle-mcp-tools.ts @@ -0,0 +1,225 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { logDebug, logWarn } from "../logger.js"; +import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js"; +import { + describeStdioMcpServerLaunchConfig, + resolveStdioMcpServerLaunchConfig, +} from "./mcp-stdio.js"; +import type { AnyAgentTool } from "./tools/common.js"; + +type BundleMcpToolRuntime = { + tools: AnyAgentTool[]; + dispose: () => Promise; +}; + +type BundleMcpSession = { + serverName: string; + client: Client; + transport: StdioClientTransport; + detachStderr?: () => void; +}; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +async function listAllTools(client: Client) { + const tools: Awaited>["tools"] = []; + let cursor: string | undefined; + do { + const page = await client.listTools(cursor ? { cursor } : undefined); + tools.push(...page.tools); + cursor = page.nextCursor; + } while (cursor); + return tools; +} + +function toAgentToolResult(params: { + serverName: string; + toolName: string; + result: CallToolResult; +}): AgentToolResult { + const content = Array.isArray(params.result.content) + ? (params.result.content as AgentToolResult["content"]) + : []; + const normalizedContent: AgentToolResult["content"] = + content.length > 0 + ? content + : params.result.structuredContent !== undefined + ? [ + { + type: "text", + text: JSON.stringify(params.result.structuredContent, null, 2), + }, + ] + : ([ + { + type: "text", + text: JSON.stringify( + { + status: params.result.isError === true ? "error" : "ok", + server: params.serverName, + tool: params.toolName, + }, + null, + 2, + ), + }, + ] as AgentToolResult["content"]); + const details: Record = { + mcpServer: params.serverName, + mcpTool: params.toolName, + }; + if (params.result.structuredContent !== undefined) { + details.structuredContent = params.result.structuredContent; + } + if (params.result.isError === true) { + details.status = "error"; + } + return { + content: normalizedContent, + details, + }; +} + +function attachStderrLogging(serverName: string, transport: StdioClientTransport) { + const stderr = transport.stderr; + if (!stderr || typeof stderr.on !== "function") { + return undefined; + } + const onData = (chunk: Buffer | string) => { + const message = String(chunk).trim(); + if (!message) { + return; + } + for (const line of message.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed) { + logDebug(`bundle-mcp:${serverName}: ${trimmed}`); + } + } + }; + stderr.on("data", onData); + return () => { + if (typeof stderr.off === "function") { + stderr.off("data", onData); + } else if (typeof stderr.removeListener === "function") { + stderr.removeListener("data", onData); + } + }; +} + +async function disposeSession(session: BundleMcpSession) { + session.detachStderr?.(); + await session.client.close().catch(() => {}); + await session.transport.close().catch(() => {}); +} + +export async function createBundleMcpToolRuntime(params: { + workspaceDir: string; + cfg?: OpenClawConfig; + reservedToolNames?: Iterable; +}): Promise { + const loaded = loadEmbeddedPiMcpConfig({ + workspaceDir: params.workspaceDir, + cfg: params.cfg, + }); + for (const diagnostic of loaded.diagnostics) { + logWarn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`); + } + + const reservedNames = new Set( + Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean), + ); + const sessions: BundleMcpSession[] = []; + const tools: AnyAgentTool[] = []; + + try { + for (const [serverName, rawServer] of Object.entries(loaded.mcpServers)) { + const launch = resolveStdioMcpServerLaunchConfig(rawServer); + if (!launch.ok) { + logWarn(`bundle-mcp: skipped server "${serverName}" because ${launch.reason}.`); + continue; + } + const launchConfig = launch.config; + + const transport = new StdioClientTransport({ + command: launchConfig.command, + args: launchConfig.args, + env: launchConfig.env, + cwd: launchConfig.cwd, + stderr: "pipe", + }); + const client = new Client( + { + name: "openclaw-bundle-mcp", + version: "0.0.0", + }, + {}, + ); + const session: BundleMcpSession = { + serverName, + client, + transport, + detachStderr: attachStderrLogging(serverName, transport), + }; + + try { + await client.connect(transport); + const listedTools = await listAllTools(client); + sessions.push(session); + for (const tool of listedTools) { + const normalizedName = tool.name.trim().toLowerCase(); + if (!normalizedName) { + continue; + } + if (reservedNames.has(normalizedName)) { + logWarn( + `bundle-mcp: skipped tool "${tool.name}" from server "${serverName}" because the name already exists.`, + ); + continue; + } + reservedNames.add(normalizedName); + tools.push({ + name: tool.name, + label: tool.title ?? tool.name, + description: + tool.description?.trim() || + `Provided by bundle MCP server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}).`, + parameters: tool.inputSchema, + execute: async (_toolCallId, input) => { + const result = (await client.callTool({ + name: tool.name, + arguments: isRecord(input) ? input : {}, + })) as CallToolResult; + return toAgentToolResult({ + serverName, + toolName: tool.name, + result, + }); + }, + }); + } + } catch (error) { + logWarn( + `bundle-mcp: failed to start server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}): ${String(error)}`, + ); + await disposeSession(session); + } + } + + return { + tools, + dispose: async () => { + await Promise.allSettled(sessions.map((session) => disposeSession(session))); + }, + }; + } catch (error) { + await Promise.allSettled(sessions.map((session) => disposeSession(session))); + throw error; + } +} diff --git a/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts b/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts new file mode 100644 index 00000000000..2eac44e922b --- /dev/null +++ b/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts @@ -0,0 +1,302 @@ +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import "./test-helpers/fast-coding-tools.js"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + cleanupEmbeddedPiRunnerTestWorkspace, + createEmbeddedPiRunnerOpenAiConfig, + createEmbeddedPiRunnerTestWorkspace, + type EmbeddedPiRunnerTestWorkspace, + immediateEnqueue, +} from "./test-helpers/pi-embedded-runner-e2e-fixtures.js"; + +const E2E_TIMEOUT_MS = 20_000; +const require = createRequire(import.meta.url); +const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js"); +const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js"); + +function createMockUsage(input: number, output: number) { + return { + input, + output, + cacheRead: 0, + cacheWrite: 0, + totalTokens: input + output, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }; +} + +let streamCallCount = 0; +let observedContexts: Array> = []; + +async function writeExecutable(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 }); +} + +async function writeBundleProbeMcpServer(filePath: string): Promise { + await writeExecutable( + filePath, + `#!/usr/bin/env node +import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)}; +import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)}; + +const server = new McpServer({ name: "bundle-probe", version: "1.0.0" }); +server.tool("bundle_probe", "Bundle MCP probe", async () => { + return { + content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }], + }; +}); + +await server.connect(new StdioServerTransport()); +`, + ); +} + +async function writeClaudeBundle(params: { + pluginRoot: string; + serverScriptPath: string; +}): Promise { + await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true }); + await fs.writeFile( + path.join(params.pluginRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + path.join(params.pluginRoot, ".mcp.json"), + `${JSON.stringify( + { + mcpServers: { + bundleProbe: { + command: "node", + args: [path.relative(params.pluginRoot, params.serverScriptPath)], + env: { + BUNDLE_PROBE_TEXT: "FROM-BUNDLE", + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); +} + +vi.mock("@mariozechner/pi-coding-agent", async () => { + return await vi.importActual( + "@mariozechner/pi-coding-agent", + ); +}); + +vi.mock("@mariozechner/pi-ai", async () => { + const actual = await vi.importActual("@mariozechner/pi-ai"); + + const buildToolUseMessage = (model: { api: string; provider: string; id: string }) => ({ + role: "assistant" as const, + content: [ + { + type: "toolCall" as const, + id: "tc-bundle-mcp-1", + name: "bundle_probe", + arguments: {}, + }, + ], + stopReason: "toolUse" as const, + api: model.api, + provider: model.provider, + model: model.id, + usage: createMockUsage(1, 1), + timestamp: Date.now(), + }); + + const buildStopMessage = ( + model: { api: string; provider: string; id: string }, + text: string, + ) => ({ + role: "assistant" as const, + content: [{ type: "text" as const, text }], + stopReason: "stop" as const, + api: model.api, + provider: model.provider, + model: model.id, + usage: createMockUsage(1, 1), + timestamp: Date.now(), + }); + + return { + ...actual, + complete: async (model: { api: string; provider: string; id: string }) => { + streamCallCount += 1; + return streamCallCount === 1 + ? buildToolUseMessage(model) + : buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE"); + }, + completeSimple: async (model: { api: string; provider: string; id: string }) => { + streamCallCount += 1; + return streamCallCount === 1 + ? buildToolUseMessage(model) + : buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE"); + }, + streamSimple: ( + model: { api: string; provider: string; id: string }, + context: { messages?: Array<{ role?: string; content?: unknown }> }, + ) => { + streamCallCount += 1; + const messages = (context.messages ?? []).map((message) => ({ ...message })); + observedContexts.push(messages); + const stream = actual.createAssistantMessageEventStream(); + queueMicrotask(() => { + if (streamCallCount === 1) { + stream.push({ + type: "done", + reason: "toolUse", + message: buildToolUseMessage(model), + }); + stream.end(); + return; + } + + const toolResultText = messages.flatMap((message) => + Array.isArray(message.content) + ? (message.content as Array<{ type?: string; text?: string }>) + .filter((entry) => entry.type === "text" && typeof entry.text === "string") + .map((entry) => entry.text ?? "") + : [], + ); + const sawBundleResult = toolResultText.some((text) => text.includes("FROM-BUNDLE")); + if (!sawBundleResult) { + stream.push({ + type: "done", + reason: "error", + message: { + role: "assistant" as const, + content: [], + stopReason: "error" as const, + errorMessage: "bundle MCP tool result missing from context", + api: model.api, + provider: model.provider, + model: model.id, + usage: createMockUsage(1, 0), + timestamp: Date.now(), + }, + }); + stream.end(); + return; + } + + stream.push({ + type: "done", + reason: "stop", + message: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE"), + }); + stream.end(); + }); + return stream; + }, + }; +}); + +let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent; +let e2eWorkspace: EmbeddedPiRunnerTestWorkspace | undefined; +let agentDir: string; +let workspaceDir: string; + +beforeAll(async () => { + vi.useRealTimers(); + ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js")); + e2eWorkspace = await createEmbeddedPiRunnerTestWorkspace("openclaw-bundle-mcp-pi-"); + ({ agentDir, workspaceDir } = e2eWorkspace); +}, 180_000); + +afterAll(async () => { + await cleanupEmbeddedPiRunnerTestWorkspace(e2eWorkspace); + e2eWorkspace = undefined; +}); + +const readSessionMessages = async (sessionFile: string) => { + const raw = await fs.readFile(sessionFile, "utf-8"); + return raw + .split(/\r?\n/) + .filter(Boolean) + .map( + (line) => + JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } }, + ) + .filter((entry) => entry.type === "message") + .map((entry) => entry.message) as Array<{ role?: string; content?: unknown }>; +}; + +describe("runEmbeddedPiAgent bundle MCP e2e", () => { + it( + "loads bundle MCP into Pi, executes the MCP tool, and includes the result in the follow-up turn", + { timeout: E2E_TIMEOUT_MS }, + async () => { + streamCallCount = 0; + observedContexts = []; + + const sessionFile = path.join(workspaceDir, "session-bundle-mcp-e2e.jsonl"); + const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe"); + const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs"); + await writeBundleProbeMcpServer(serverScriptPath); + await writeClaudeBundle({ pluginRoot, serverScriptPath }); + + const cfg = { + ...createEmbeddedPiRunnerOpenAiConfig(["mock-bundle-mcp"]), + plugins: { + entries: { + "bundle-probe": { enabled: true }, + }, + }, + }; + + const result = await runEmbeddedPiAgent({ + sessionId: "bundle-mcp-e2e", + sessionKey: "agent:test:bundle-mcp-e2e", + sessionFile, + workspaceDir, + config: cfg, + prompt: "Use the bundle MCP tool and report its result.", + provider: "openai", + model: "mock-bundle-mcp", + timeoutMs: 10_000, + agentDir, + runId: "run-bundle-mcp-e2e", + enqueue: immediateEnqueue, + }); + + expect(result.meta.stopReason).toBe("stop"); + expect(result.payloads?.[0]?.text).toContain("BUNDLE MCP OK FROM-BUNDLE"); + expect(streamCallCount).toBe(2); + + const followUpContext = observedContexts[1] ?? []; + const followUpTexts = followUpContext.flatMap((message) => + Array.isArray(message.content) + ? (message.content as Array<{ type?: string; text?: string }>) + .filter((entry) => entry.type === "text" && typeof entry.text === "string") + .map((entry) => entry.text ?? "") + : [], + ); + expect(followUpTexts.some((text) => text.includes("FROM-BUNDLE"))).toBe(true); + + const messages = await readSessionMessages(sessionFile); + const toolResults = messages.filter((message) => message?.role === "toolResult"); + const toolResultText = toolResults.flatMap((message) => + Array.isArray(message.content) + ? (message.content as Array<{ type?: string; text?: string }>) + .filter((entry) => entry.type === "text" && typeof entry.text === "string") + .map((entry) => entry.text ?? "") + : [], + ); + expect(toolResultText.some((text) => text.includes("FROM-BUNDLE"))).toBe(true); + }, + ); +}); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 4daef42a21f..98a3b438d21 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -53,6 +53,7 @@ import { supportsModelTools } from "../model-tool-support.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; import { createConfiguredOllamaStreamFn } from "../ollama-stream.js"; import { resolveOwnerDisplaySetting } from "../owner-display.js"; +import { createBundleMcpToolRuntime } from "../pi-bundle-mcp-tools.js"; import { ensureSessionHeader, validateAnthropicTurns, @@ -583,12 +584,24 @@ export async function compactEmbeddedPiSessionDirect( modelContextWindowTokens: ctxInfo.tokens, modelAuthMode: resolveModelAuthMode(model.provider, params.config), }); + const toolsEnabled = supportsModelTools(runtimeModel); const tools = sanitizeToolsForGoogle({ - tools: supportsModelTools(runtimeModel) ? toolsRaw : [], + tools: toolsEnabled ? toolsRaw : [], provider, }); - const allowedToolNames = collectAllowedToolNames({ tools }); - logToolSchemasForGoogle({ tools, provider }); + const bundleMcpRuntime = toolsEnabled + ? await createBundleMcpToolRuntime({ + workspaceDir: effectiveWorkspace, + cfg: params.config, + reservedToolNames: tools.map((tool) => tool.name), + }) + : undefined; + const effectiveTools = + bundleMcpRuntime && bundleMcpRuntime.tools.length > 0 + ? [...tools, ...bundleMcpRuntime.tools] + : tools; + const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools }); + logToolSchemasForGoogle({ tools: effectiveTools, provider }); const machineName = await getMachineDisplayName(); const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider); let runtimeCapabilities = runtimeChannel @@ -705,7 +718,7 @@ export async function compactEmbeddedPiSessionDirect( reactionGuidance, messageToolHints, sandboxInfo, - tools, + tools: effectiveTools, modelAliasLines: buildModelAliasLines(params.config), userTimezone, userTime, @@ -768,7 +781,7 @@ export async function compactEmbeddedPiSessionDirect( } const { builtInTools, customTools } = splitSdkTools({ - tools, + tools: effectiveTools, sandboxEnabled: !!sandbox?.enabled, }); @@ -1060,6 +1073,7 @@ export async function compactEmbeddedPiSessionDirect( clearPendingOnTimeout: true, }); session.dispose(); + await bundleMcpRuntime?.dispose(); } } finally { await sessionLock.release(); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 0ea66825ff1..dc9df12865d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -59,6 +59,7 @@ import { supportsModelTools } from "../../model-tool-support.js"; import { createConfiguredOllamaStreamFn } from "../../ollama-stream.js"; import { createOpenAIWebSocketStreamFn, releaseWsSession } from "../../openai-ws-stream.js"; import { resolveOwnerDisplaySetting } from "../../owner-display.js"; +import { createBundleMcpToolRuntime } from "../../pi-bundle-mcp-tools.js"; import { downgradeOpenAIFunctionCallReasoningPairs, isCloudCodeAssistFormatError, @@ -1547,11 +1548,25 @@ export async function runEmbeddedAttempt( provider: params.provider, }); const clientTools = toolsEnabled ? params.clientTools : undefined; + const bundleMcpRuntime = toolsEnabled + ? await createBundleMcpToolRuntime({ + workspaceDir: effectiveWorkspace, + cfg: params.config, + reservedToolNames: [ + ...tools.map((tool) => tool.name), + ...(clientTools?.map((tool) => tool.function.name) ?? []), + ], + }) + : undefined; + const effectiveTools = + bundleMcpRuntime && bundleMcpRuntime.tools.length > 0 + ? [...tools, ...bundleMcpRuntime.tools] + : tools; const allowedToolNames = collectAllowedToolNames({ - tools, + tools: effectiveTools, clientTools, }); - logToolSchemasForGoogle({ tools, provider: params.provider }); + logToolSchemasForGoogle({ tools: effectiveTools, provider: params.provider }); const machineName = await getMachineDisplayName(); const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider); @@ -1673,7 +1688,7 @@ export async function runEmbeddedAttempt( runtimeInfo, messageToolHints, sandboxInfo, - tools, + tools: effectiveTools, modelAliasLines: buildModelAliasLines(params.config), userTimezone, userTime, @@ -1708,7 +1723,7 @@ export async function runEmbeddedAttempt( bootstrapFiles: hookAdjustedBootstrapFiles, injectedFiles: contextFiles, skillsPrompt, - tools, + tools: effectiveTools, }); const systemPromptOverride = createSystemPromptOverride(appendPrompt); let systemPromptText = systemPromptOverride(); @@ -1808,7 +1823,7 @@ export async function runEmbeddedAttempt( const hookRunner = getGlobalHookRunner(); const { builtInTools, customTools } = splitSdkTools({ - tools, + tools: effectiveTools, sandboxEnabled: !!sandbox?.enabled, }); @@ -2868,6 +2883,7 @@ export async function runEmbeddedAttempt( }); session?.dispose(); releaseWsSession(params.sessionId); + await bundleMcpRuntime?.dispose(); await sessionLock.release(); } } finally { diff --git a/src/agents/pi-project-settings.bundle.test.ts b/src/agents/pi-project-settings.bundle.test.ts index d297b1ef3a1..5859e18ac6e 100644 --- a/src/agents/pi-project-settings.bundle.test.ts +++ b/src/agents/pi-project-settings.bundle.test.ts @@ -79,6 +79,106 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => { expect(snapshot.compaction?.keepRecentTokens).toBe(64_000); }); + it("loads enabled bundle MCP servers into the Pi settings snapshot", async () => { + const workspaceDir = await tempDirs.make("openclaw-workspace-"); + const pluginRoot = await tempDirs.make("openclaw-bundle-"); + await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + await fs.mkdir(path.join(pluginRoot, "servers"), { recursive: true }); + await fs.writeFile( + path.join(pluginRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "claude-bundle", + }), + "utf-8", + ); + await fs.writeFile( + path.join(pluginRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + bundleProbe: { + command: "node", + args: ["./servers/probe.mjs"], + }, + }, + }), + "utf-8", + ); + hoisted.loadPluginManifestRegistry.mockReturnValue( + buildRegistry({ pluginRoot, settingsFiles: [] }), + ); + + const snapshot = loadEnabledBundlePiSettingsSnapshot({ + cwd: workspaceDir, + cfg: { + plugins: { + entries: { + "claude-bundle": { enabled: true }, + }, + }, + }, + }); + + expect(snapshot.mcpServers).toEqual({ + bundleProbe: { + command: "node", + args: [path.join(pluginRoot, "servers", "probe.mjs")], + cwd: pluginRoot, + }, + }); + }); + + it("lets top-level MCP config override bundle MCP defaults", async () => { + const workspaceDir = await tempDirs.make("openclaw-workspace-"); + const pluginRoot = await tempDirs.make("openclaw-bundle-"); + await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + await fs.writeFile( + path.join(pluginRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "claude-bundle", + }), + "utf-8", + ); + await fs.writeFile( + path.join(pluginRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + sharedServer: { + command: "node", + args: ["./servers/bundle.mjs"], + }, + }, + }), + "utf-8", + ); + hoisted.loadPluginManifestRegistry.mockReturnValue( + buildRegistry({ pluginRoot, settingsFiles: [] }), + ); + + const snapshot = loadEnabledBundlePiSettingsSnapshot({ + cwd: workspaceDir, + cfg: { + mcp: { + servers: { + sharedServer: { + url: "https://example.com/mcp", + }, + }, + }, + plugins: { + entries: { + "claude-bundle": { enabled: true }, + }, + }, + }, + }); + + expect(snapshot.mcpServers).toEqual({ + sharedServer: { + url: "https://example.com/mcp", + }, + }); + }); + it("ignores disabled bundle plugins", async () => { const workspaceDir = await tempDirs.make("openclaw-workspace-"); const pluginRoot = await tempDirs.make("openclaw-bundle-"); diff --git a/src/agents/pi-project-settings.test.ts b/src/agents/pi-project-settings.test.ts index 92d676b8427..2ec9edf523d 100644 --- a/src/agents/pi-project-settings.test.ts +++ b/src/agents/pi-project-settings.test.ts @@ -93,4 +93,34 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { expect(snapshot.compaction?.reserveTokens).toBe(32_000); expect(snapshot.hideThinkingBlock).toBe(true); }); + + it("lets project Pi settings override bundle MCP defaults", () => { + const snapshot = buildEmbeddedPiSettingsSnapshot({ + globalSettings, + pluginSettings: { + mcpServers: { + bundleProbe: { + command: "node", + args: ["/plugins/probe.mjs"], + }, + }, + }, + projectSettings: { + mcpServers: { + bundleProbe: { + command: "deno", + args: ["/workspace/probe.ts"], + }, + }, + }, + policy: "sanitize", + }); + + expect(snapshot.mcpServers).toEqual({ + bundleProbe: { + command: "deno", + args: ["/workspace/probe.ts"], + }, + }); + }); }); diff --git a/src/agents/pi-project-settings.ts b/src/agents/pi-project-settings.ts index 8e08d11bca7..fd66a6ee393 100644 --- a/src/agents/pi-project-settings.ts +++ b/src/agents/pi-project-settings.ts @@ -8,6 +8,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { isRecord } from "../utils.js"; +import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js"; import { applyPiCompactionSettingsFromConfig } from "./pi-settings.js"; const log = createSubsystemLogger("embedded-pi-settings"); @@ -107,6 +108,19 @@ export function loadEnabledBundlePiSettingsSnapshot(params: { } } + const embeddedPiMcp = loadEmbeddedPiMcpConfig({ + workspaceDir, + cfg: params.cfg, + }); + for (const diagnostic of embeddedPiMcp.diagnostics) { + log.warn(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`); + } + if (Object.keys(embeddedPiMcp.mcpServers).length > 0) { + snapshot = applyMergePatch(snapshot, { + mcpServers: embeddedPiMcp.mcpServers, + }) as PiSettingsSnapshot; + } + return snapshot; } diff --git a/src/auto-reply/commands-args.ts b/src/auto-reply/commands-args.ts index ab49b9ea68a..6f37414c053 100644 --- a/src/auto-reply/commands-args.ts +++ b/src/auto-reply/commands-args.ts @@ -51,6 +51,16 @@ const formatConfigArgs: CommandArgsFormatter = (values) => }, }); +const formatMcpArgs: CommandArgsFormatter = (values) => + formatActionArgs(values, { + formatKnownAction: (action, path) => { + if (action === "show" || action === "get") { + return path ? `${action} ${path}` : action; + } + return undefined; + }, + }); + const formatDebugArgs: CommandArgsFormatter = (values) => formatActionArgs(values, { formatKnownAction: (action) => { @@ -124,6 +134,7 @@ const formatExecArgs: CommandArgsFormatter = (values) => { export const COMMAND_ARG_FORMATTERS: Record = { config: formatConfigArgs, + mcp: formatMcpArgs, debug: formatDebugArgs, queue: formatQueueArgs, exec: formatExecArgs, diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 58064473543..d4d4da530d3 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -452,6 +452,34 @@ function buildChatCommands(): ChatCommandDefinition[] { argsParsing: "none", formatArgs: COMMAND_ARG_FORMATTERS.config, }), + defineChatCommand({ + key: "mcp", + nativeName: "mcp", + description: "Show or set embedded Pi MCP servers.", + textAlias: "/mcp", + category: "management", + args: [ + { + name: "action", + description: "show | get | set | unset", + type: "string", + choices: ["show", "get", "set", "unset"], + }, + { + name: "path", + description: "MCP server name", + type: "string", + }, + { + name: "value", + description: "JSON config for set", + type: "string", + captureRemaining: true, + }, + ], + argsParsing: "none", + formatArgs: COMMAND_ARG_FORMATTERS.mcp, + }), defineChatCommand({ key: "debug", nativeName: "debug", diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 93f8872e37b..8b0d7a5b5d6 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -99,6 +99,9 @@ export function isCommandEnabled(cfg: OpenClawConfig, commandKey: string): boole if (commandKey === "config") { return isCommandFlagEnabled(cfg, "config"); } + if (commandKey === "mcp") { + return isCommandFlagEnabled(cfg, "mcp"); + } if (commandKey === "debug") { return isCommandFlagEnabled(cfg, "debug"); } diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 7a6cc36c05e..f969c9f5f24 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -22,6 +22,7 @@ import { handleStatusCommand, handleWhoamiCommand, } from "./commands-info.js"; +import { handleMcpCommand } from "./commands-mcp.js"; import { handleModelsCommand } from "./commands-models.js"; import { handlePluginCommand } from "./commands-plugin.js"; import { @@ -194,6 +195,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-command-mcp-")); + tempDirs.push(dir); + return dir; +} + +function buildCfg(): OpenClawConfig { + return { + commands: { + text: true, + mcp: true, + }, + }; +} + +describe("handleCommands /mcp", () => { + afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it("writes MCP config and shows it back", async () => { + await withTempHome("openclaw-command-mcp-home-", async () => { + const workspaceDir = await createWorkspace(); + const setParams = buildCommandTestParams( + '/mcp set context7={"command":"uvx","args":["context7-mcp"]}', + buildCfg(), + undefined, + { workspaceDir }, + ); + setParams.command.senderIsOwner = true; + + const setResult = await handleCommands(setParams); + expect(setResult.reply?.text).toContain('MCP server "context7" saved'); + + const showParams = buildCommandTestParams("/mcp show context7", buildCfg(), undefined, { + workspaceDir, + }); + showParams.command.senderIsOwner = true; + const showResult = await handleCommands(showParams); + expect(showResult.reply?.text).toContain('"command": "uvx"'); + expect(showResult.reply?.text).toContain('"args": ['); + }); + }); + + it("rejects internal writes without operator.admin", async () => { + await withTempHome("openclaw-command-mcp-home-", async () => { + const workspaceDir = await createWorkspace(); + const params = buildCommandTestParams( + '/mcp set context7={"command":"uvx","args":["context7-mcp"]}', + buildCfg(), + { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.write"], + }, + { workspaceDir }, + ); + params.command.senderIsOwner = true; + + const result = await handleCommands(params); + expect(result.reply?.text).toContain("requires operator.admin"); + }); + }); + + it("accepts non-stdio MCP config at the config layer", async () => { + await withTempHome("openclaw-command-mcp-home-", async () => { + const workspaceDir = await createWorkspace(); + const params = buildCommandTestParams( + '/mcp set remote={"url":"https://example.com/mcp"}', + buildCfg(), + undefined, + { workspaceDir }, + ); + params.command.senderIsOwner = true; + + const result = await handleCommands(params); + expect(result.reply?.text).toContain('MCP server "remote" saved'); + }); + }); +}); diff --git a/src/auto-reply/reply/commands-mcp.ts b/src/auto-reply/reply/commands-mcp.ts new file mode 100644 index 00000000000..ff805a9b878 --- /dev/null +++ b/src/auto-reply/reply/commands-mcp.ts @@ -0,0 +1,134 @@ +import { + listConfiguredMcpServers, + setConfiguredMcpServer, + unsetConfiguredMcpServer, +} from "../../config/mcp-config.js"; +import { isInternalMessageChannel } from "../../utils/message-channel.js"; +import { + rejectNonOwnerCommand, + rejectUnauthorizedCommand, + requireCommandFlagEnabled, + requireGatewayClientScopeForInternalChannel, +} from "./command-gates.js"; +import type { CommandHandler } from "./commands-types.js"; +import { parseMcpCommand } from "./mcp-commands.js"; + +function renderJsonBlock(label: string, value: unknown): string { + return `${label}\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``; +} + +export const handleMcpCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } + const mcpCommand = parseMcpCommand(params.command.commandBodyNormalized); + if (!mcpCommand) { + return null; + } + const unauthorized = rejectUnauthorizedCommand(params, "/mcp"); + if (unauthorized) { + return unauthorized; + } + const allowInternalReadOnlyShow = + mcpCommand.action === "show" && isInternalMessageChannel(params.command.channel); + const nonOwner = allowInternalReadOnlyShow ? null : rejectNonOwnerCommand(params, "/mcp"); + if (nonOwner) { + return nonOwner; + } + const disabled = requireCommandFlagEnabled(params.cfg, { + label: "/mcp", + configKey: "mcp", + }); + if (disabled) { + return disabled; + } + if (mcpCommand.action === "error") { + return { + shouldContinue: false, + reply: { text: `⚠️ ${mcpCommand.message}` }, + }; + } + + if (mcpCommand.action === "show") { + const loaded = await listConfiguredMcpServers(); + if (!loaded.ok) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${loaded.error}` }, + }; + } + if (mcpCommand.name) { + const server = loaded.mcpServers[mcpCommand.name]; + if (!server) { + return { + shouldContinue: false, + reply: { text: `🔌 No MCP server named "${mcpCommand.name}" in ${loaded.path}.` }, + }; + } + return { + shouldContinue: false, + reply: { + text: renderJsonBlock(`🔌 MCP server "${mcpCommand.name}" (${loaded.path})`, server), + }, + }; + } + if (Object.keys(loaded.mcpServers).length === 0) { + return { + shouldContinue: false, + reply: { text: `🔌 No MCP servers configured in ${loaded.path}.` }, + }; + } + return { + shouldContinue: false, + reply: { + text: renderJsonBlock(`🔌 MCP servers (${loaded.path})`, loaded.mcpServers), + }, + }; + } + + const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, { + label: "/mcp write", + allowedScopes: ["operator.admin"], + missingText: "❌ /mcp set|unset requires operator.admin for gateway clients.", + }); + if (missingAdminScope) { + return missingAdminScope; + } + + if (mcpCommand.action === "set") { + const result = await setConfiguredMcpServer({ + name: mcpCommand.name, + server: mcpCommand.value, + }); + if (!result.ok) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${result.error}` }, + }; + } + return { + shouldContinue: false, + reply: { + text: `🔌 MCP server "${mcpCommand.name}" saved to ${result.path}.`, + }, + }; + } + + const result = await unsetConfiguredMcpServer({ name: mcpCommand.name }); + if (!result.ok) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${result.error}` }, + }; + } + if (!result.removed) { + return { + shouldContinue: false, + reply: { text: `🔌 No MCP server named "${mcpCommand.name}" in ${result.path}.` }, + }; + } + return { + shouldContinue: false, + reply: { text: `🔌 MCP server "${mcpCommand.name}" removed from ${result.path}.` }, + }; +}; diff --git a/src/auto-reply/reply/mcp-commands.ts b/src/auto-reply/reply/mcp-commands.ts new file mode 100644 index 00000000000..506efe015df --- /dev/null +++ b/src/auto-reply/reply/mcp-commands.ts @@ -0,0 +1,24 @@ +import { parseStandardSetUnsetSlashCommand } from "./commands-setunset-standard.js"; + +export type McpCommand = + | { action: "show"; name?: string } + | { action: "set"; name: string; value: unknown } + | { action: "unset"; name: string } + | { action: "error"; message: string }; + +export function parseMcpCommand(raw: string): McpCommand | null { + return parseStandardSetUnsetSlashCommand({ + raw, + slash: "/mcp", + invalidMessage: "Invalid /mcp syntax.", + usageMessage: "Usage: /mcp show|set|unset", + onKnownAction: (action, args) => { + if (action === "show" || action === "get") { + return { action: "show", name: args || undefined }; + } + return undefined; + }, + onSet: (name, value) => ({ action: "set", name, value }), + onUnset: (name) => ({ action: "unset", name }), + }); +} diff --git a/src/cli/mcp-cli.test.ts b/src/cli/mcp-cli.test.ts new file mode 100644 index 00000000000..299406d5f31 --- /dev/null +++ b/src/cli/mcp-cli.test.ts @@ -0,0 +1,83 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../config/home-env.test-harness.js"; + +const mockLog = vi.fn(); +const mockError = vi.fn(); +const mockExit = vi.fn((code: number) => { + throw new Error(`__exit__:${code}`); +}); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: { + log: (...args: unknown[]) => mockLog(...args), + error: (...args: unknown[]) => mockError(...args), + exit: (code: number) => mockExit(code), + }, +})); + +const tempDirs: string[] = []; + +async function createWorkspace(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-mcp-")); + tempDirs.push(dir); + return dir; +} + +let registerMcpCli: typeof import("./mcp-cli.js").registerMcpCli; +let sharedProgram: Command; +let previousCwd = process.cwd(); + +async function runMcpCommand(args: string[]) { + await sharedProgram.parseAsync(args, { from: "user" }); +} + +describe("mcp cli", () => { + beforeAll(async () => { + ({ registerMcpCli } = await import("./mcp-cli.js")); + sharedProgram = new Command(); + sharedProgram.exitOverride(); + registerMcpCli(sharedProgram); + }, 300_000); + + beforeEach(() => { + vi.clearAllMocks(); + previousCwd = process.cwd(); + }); + + afterEach(async () => { + process.chdir(previousCwd); + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it("sets and shows a configured MCP server", async () => { + await withTempHome("openclaw-cli-mcp-home-", async () => { + const workspaceDir = await createWorkspace(); + process.chdir(workspaceDir); + + await runMcpCommand(["mcp", "set", "context7", '{"command":"uvx","args":["context7-mcp"]}']); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Saved MCP server "context7"')); + + mockLog.mockClear(); + await runMcpCommand(["mcp", "show", "context7", "--json"]); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('"command": "uvx"')); + }); + }); + + it("fails when removing an unknown MCP server", async () => { + await withTempHome("openclaw-cli-mcp-home-", async () => { + const workspaceDir = await createWorkspace(); + process.chdir(workspaceDir); + + await expect(runMcpCommand(["mcp", "unset", "missing"])).rejects.toThrow("__exit__:1"); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining('No MCP server named "missing"'), + ); + }); + }); +}); diff --git a/src/cli/mcp-cli.ts b/src/cli/mcp-cli.ts new file mode 100644 index 00000000000..62831ee827d --- /dev/null +++ b/src/cli/mcp-cli.ts @@ -0,0 +1,103 @@ +import { Command } from "commander"; +import { parseConfigValue } from "../auto-reply/reply/config-value.js"; +import { + listConfiguredMcpServers, + setConfiguredMcpServer, + unsetConfiguredMcpServer, +} from "../config/mcp-config.js"; +import { defaultRuntime } from "../runtime.js"; + +function fail(message: string): never { + defaultRuntime.error(message); + defaultRuntime.exit(1); +} + +function printJson(value: unknown): void { + defaultRuntime.log(JSON.stringify(value, null, 2)); +} + +export function registerMcpCli(program: Command) { + const mcp = program.command("mcp").description("Manage OpenClaw MCP server config"); + + mcp + .command("list") + .description("List configured MCP servers") + .option("--json", "Print JSON") + .action(async (opts: { json?: boolean }) => { + const loaded = await listConfiguredMcpServers(); + if (!loaded.ok) { + fail(loaded.error); + } + if (opts.json) { + printJson(loaded.mcpServers); + return; + } + const names = Object.keys(loaded.mcpServers).toSorted(); + if (names.length === 0) { + defaultRuntime.log(`No MCP servers configured in ${loaded.path}.`); + return; + } + defaultRuntime.log(`MCP servers (${loaded.path}):`); + for (const name of names) { + defaultRuntime.log(`- ${name}`); + } + }); + + mcp + .command("show") + .description("Show one configured MCP server or the full MCP config") + .argument("[name]", "MCP server name") + .option("--json", "Print JSON") + .action(async (name: string | undefined, opts: { json?: boolean }) => { + const loaded = await listConfiguredMcpServers(); + if (!loaded.ok) { + fail(loaded.error); + } + const value = name ? loaded.mcpServers[name] : loaded.mcpServers; + if (name && !value) { + fail(`No MCP server named "${name}" in ${loaded.path}.`); + } + if (opts.json) { + printJson(value ?? {}); + return; + } + if (name) { + defaultRuntime.log(`MCP server "${name}" (${loaded.path}):`); + } else { + defaultRuntime.log(`MCP servers (${loaded.path}):`); + } + printJson(value ?? {}); + }); + + mcp + .command("set") + .description("Set one configured MCP server from a JSON object") + .argument("", "MCP server name") + .argument("", 'JSON object, for example {"command":"uvx","args":["context7-mcp"]}') + .action(async (name: string, rawValue: string) => { + const parsed = parseConfigValue(rawValue); + if (parsed.error) { + fail(parsed.error); + } + const result = await setConfiguredMcpServer({ name, server: parsed.value }); + if (!result.ok) { + fail(result.error); + } + defaultRuntime.log(`Saved MCP server "${name}" to ${result.path}.`); + }); + + mcp + .command("unset") + .description("Remove one configured MCP server") + .argument("", "MCP server name") + .action(async (name: string) => { + const result = await unsetConfiguredMcpServer({ name }); + if (!result.ok) { + fail(result.error); + } + if (!result.removed) { + fail(`No MCP server named "${name}" in ${result.path}.`); + } + defaultRuntime.log(`Removed MCP server "${name}" from ${result.path}.`); + }); +} diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 1955e851357..93c4616594e 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -160,6 +160,19 @@ const coreEntries: CoreCliEntry[] = [ mod.registerMemoryCli(program); }, }, + { + commands: [ + { + name: "mcp", + description: "Manage embedded Pi MCP servers", + hasSubcommands: true, + }, + ], + register: async ({ program }) => { + const mod = await import("../mcp-cli.js"); + mod.registerMcpCli(program); + }, + }, { commands: [ { diff --git a/src/config/mcp-config.test.ts b/src/config/mcp-config.test.ts new file mode 100644 index 00000000000..bd7032fb8a4 --- /dev/null +++ b/src/config/mcp-config.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { + listConfiguredMcpServers, + setConfiguredMcpServer, + unsetConfiguredMcpServer, +} from "./mcp-config.js"; +import { withTempHomeConfig } from "./test-helpers.js"; + +describe("config mcp config", () => { + it("writes and removes top-level mcp servers", async () => { + await withTempHomeConfig({}, async () => { + const setResult = await setConfiguredMcpServer({ + name: "context7", + server: { + command: "uvx", + args: ["context7-mcp"], + }, + }); + + expect(setResult.ok).toBe(true); + const loaded = await listConfiguredMcpServers(); + expect(loaded.ok).toBe(true); + if (!loaded.ok) { + throw new Error("expected MCP config to load"); + } + expect(loaded.mcpServers.context7).toEqual({ + command: "uvx", + args: ["context7-mcp"], + }); + + const unsetResult = await unsetConfiguredMcpServer({ name: "context7" }); + expect(unsetResult.ok).toBe(true); + + const reloaded = await listConfiguredMcpServers(); + expect(reloaded.ok).toBe(true); + if (!reloaded.ok) { + throw new Error("expected MCP config to reload"); + } + expect(reloaded.mcpServers).toEqual({}); + }); + }); + + it("fails closed when the config file is invalid", async () => { + await withTempHomeConfig({}, async ({ configPath }) => { + await fs.writeFile(configPath, "{", "utf-8"); + + const loaded = await listConfiguredMcpServers(); + expect(loaded.ok).toBe(false); + if (loaded.ok) { + throw new Error("expected invalid config to fail"); + } + expect(loaded.path).toBe(configPath); + }); + }); +}); diff --git a/src/config/mcp-config.ts b/src/config/mcp-config.ts new file mode 100644 index 00000000000..eb24e3c0ae4 --- /dev/null +++ b/src/config/mcp-config.ts @@ -0,0 +1,150 @@ +import { readConfigFileSnapshot, writeConfigFile } from "./io.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; +import { validateConfigObjectWithPlugins } from "./validation.js"; + +export type ConfigMcpServers = Record>; + +type ConfigMcpReadResult = + | { ok: true; path: string; config: OpenClawConfig; mcpServers: ConfigMcpServers } + | { ok: false; path: string; error: string }; + +type ConfigMcpWriteResult = + | { + ok: true; + path: string; + config: OpenClawConfig; + mcpServers: ConfigMcpServers; + removed?: boolean; + } + | { ok: false; path: string; error: string }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function normalizeConfiguredMcpServers(value: unknown): ConfigMcpServers { + if (!isRecord(value)) { + return {}; + } + return Object.fromEntries( + Object.entries(value) + .filter(([, server]) => isRecord(server)) + .map(([name, server]) => [name, { ...(server as Record) }]), + ); +} + +export async function listConfiguredMcpServers(): Promise { + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.valid) { + return { + ok: false, + path: snapshot.path, + error: "Config file is invalid; fix it before using MCP config commands.", + }; + } + return { + ok: true, + path: snapshot.path, + config: structuredClone(snapshot.resolved), + mcpServers: normalizeConfiguredMcpServers(snapshot.resolved.mcp?.servers), + }; +} + +export async function setConfiguredMcpServer(params: { + name: string; + server: unknown; +}): Promise { + const name = params.name.trim(); + if (!name) { + return { ok: false, path: "", error: "MCP server name is required." }; + } + if (!isRecord(params.server)) { + return { ok: false, path: "", error: "MCP server config must be a JSON object." }; + } + + const loaded = await listConfiguredMcpServers(); + if (!loaded.ok) { + return loaded; + } + + const next = structuredClone(loaded.config); + const servers = normalizeConfiguredMcpServers(next.mcp?.servers); + servers[name] = { ...params.server }; + next.mcp = { + ...next.mcp, + servers, + }; + + const validated = validateConfigObjectWithPlugins(next); + if (!validated.ok) { + const issue = validated.issues[0]; + return { + ok: false, + path: loaded.path, + error: `Config invalid after MCP set (${issue.path}: ${issue.message}).`, + }; + } + await writeConfigFile(validated.config); + return { + ok: true, + path: loaded.path, + config: validated.config, + mcpServers: servers, + }; +} + +export async function unsetConfiguredMcpServer(params: { + name: string; +}): Promise { + const name = params.name.trim(); + if (!name) { + return { ok: false, path: "", error: "MCP server name is required." }; + } + + const loaded = await listConfiguredMcpServers(); + if (!loaded.ok) { + return loaded; + } + if (!Object.hasOwn(loaded.mcpServers, name)) { + return { + ok: true, + path: loaded.path, + config: loaded.config, + mcpServers: loaded.mcpServers, + removed: false, + }; + } + + const next = structuredClone(loaded.config); + const servers = normalizeConfiguredMcpServers(next.mcp?.servers); + delete servers[name]; + if (Object.keys(servers).length > 0) { + next.mcp = { + ...next.mcp, + servers, + }; + } else if (next.mcp) { + delete next.mcp.servers; + if (Object.keys(next.mcp).length === 0) { + delete next.mcp; + } + } + + const validated = validateConfigObjectWithPlugins(next); + if (!validated.ok) { + const issue = validated.issues[0]; + return { + ok: false, + path: loaded.path, + error: `Config invalid after MCP unset (${issue.path}: ${issue.message}).`, + }; + } + await writeConfigFile(validated.config); + return { + ok: true, + path: loaded.path, + config: validated.config, + mcpServers: servers, + removed: true, + }; +} diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 02103650589..02d9ea5f6c9 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1093,6 +1093,8 @@ export const FIELD_HELP: Record = { "commands.bashForegroundMs": "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", "commands.config": "Allow /config chat command to read/write config on disk (default: false).", + "commands.mcp": + "Allow /mcp chat command to manage OpenClaw MCP server config under mcp.servers (default: false).", "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", "commands.restart": "Allow /restart and gateway restart tool actions (default: true).", "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", @@ -1104,6 +1106,9 @@ export const FIELD_HELP: Record = { "Optional secret used to HMAC hash owner IDs when ownerDisplay=hash. Prefer env substitution.", "commands.allowFrom": "Defines elevated command allow rules by channel and sender for owner-level command surfaces. Use narrow provider-specific identities so privileged commands are not exposed to broad chat audiences.", + mcp: "Global MCP server definitions managed by OpenClaw. Embedded Pi and other runtime adapters can consume these servers without storing them inside Pi-owned project settings.", + "mcp.servers": + "Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.", session: "Global session routing, reset, delivery policy, and maintenance controls for conversation history behavior. Keep defaults unless you need stricter isolation, retention, or delivery constraints.", "session.scope": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index a88cdc1ded5..f00b9fd9226 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -503,6 +503,7 @@ export const FIELD_LABELS: Record = { "commands.bash": "Allow Bash Chat Command", "commands.bashForegroundMs": "Bash Foreground Window (ms)", "commands.config": "Allow /config", + "commands.mcp": "Allow /mcp", "commands.debug": "Allow /debug", "commands.restart": "Allow Restart", "commands.useAccessGroups": "Use Access Groups", @@ -510,6 +511,8 @@ export const FIELD_LABELS: Record = { "commands.ownerDisplay": "Owner ID Display", "commands.ownerDisplaySecret": "Owner ID Hash Secret", // pragma: allowlist secret "commands.allowFrom": "Command Elevated Access Rules", + mcp: "MCP", + "mcp.servers": "MCP Servers", ui: "UI", "ui.seamColor": "Accent Color", "ui.assistant": "Assistant Appearance", diff --git a/src/config/types.mcp.ts b/src/config/types.mcp.ts new file mode 100644 index 00000000000..9d6b5e5a1d6 --- /dev/null +++ b/src/config/types.mcp.ts @@ -0,0 +1,14 @@ +export type McpServerConfig = { + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + workingDirectory?: string; + url?: string; + [key: string]: unknown; +}; + +export type McpConfig = { + /** Named MCP server definitions managed by OpenClaw. */ + servers?: Record; +}; diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 002a1200b8b..e6f976f2df2 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -148,6 +148,8 @@ export type CommandsConfig = { bashForegroundMs?: number; /** Allow /config command (default: false). */ config?: boolean; + /** Allow /mcp command for project-local embedded Pi MCP settings (default: false). */ + mcp?: boolean; /** Allow /debug command (default: false). */ debug?: boolean; /** Allow restart commands/tools (default: true). */ diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 3d1f0a90080..9997ecc6f84 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -14,6 +14,7 @@ import type { TalkConfig, } from "./types.gateway.js"; import type { HooksConfig } from "./types.hooks.js"; +import type { McpConfig } from "./types.mcp.js"; import type { MemoryConfig } from "./types.memory.js"; import type { AudioConfig, @@ -120,6 +121,7 @@ export type OpenClawConfig = { talk?: TalkConfig; gateway?: GatewayConfig; memory?: MemoryConfig; + mcp?: McpConfig; }; export type ConfigValidationIssue = { diff --git a/src/config/types.ts b/src/config/types.ts index 52e45b32aaf..47c46e48c68 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -33,3 +33,4 @@ export * from "./types.tts.js"; export * from "./types.tools.js"; export * from "./types.whatsapp.js"; export * from "./types.memory.js"; +export * from "./types.mcp.js"; diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index b8bb99b1b14..08a3af7c911 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -200,6 +200,7 @@ export const CommandsSchema = z bash: z.boolean().optional(), bashForegroundMs: z.number().int().min(0).max(30_000).optional(), config: z.boolean().optional(), + mcp: z.boolean().optional(), debug: z.boolean().optional(), restart: z.boolean().optional().default(true), useAccessGroups: z.boolean().optional(), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 817183cab5d..b32a86dc68f 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -203,6 +203,24 @@ const TalkSchema = z } }); +const McpServerSchema = z + .object({ + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), + cwd: z.string().optional(), + workingDirectory: z.string().optional(), + url: HttpUrlSchema.optional(), + }) + .catchall(z.unknown()); + +const McpConfigSchema = z + .object({ + servers: z.record(z.string(), McpServerSchema).optional(), + }) + .strict() + .optional(); + export const OpenClawSchema = z .object({ $schema: z.string().optional(), @@ -851,6 +869,7 @@ export const OpenClawSchema = z }) .optional(), memory: MemorySchema, + mcp: McpConfigSchema, skills: z .object({ allowBundled: z.array(z.string()).optional(), diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index 939580f9cfe..ce4c460baf0 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -81,6 +81,7 @@ describe("loadEnabledBundleMcpConfig", () => { const loadedServer = loaded.config.mcpServers.bundleProbe; const loadedArgs = getServerArgs(loadedServer); const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined; + const resolvedPluginRoot = await fs.realpath(pluginRoot); expect(loaded.diagnostics).toEqual([]); expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node"); @@ -90,6 +91,7 @@ describe("loadEnabledBundleMcpConfig", () => { throw new Error("expected bundled MCP args to include the server path"); } expect(await fs.realpath(loadedServerPath)).toBe(resolvedServerPath); + expect(loadedServer.cwd).toBe(resolvedPluginRoot); } finally { env.restore(); } @@ -164,4 +166,67 @@ describe("loadEnabledBundleMcpConfig", () => { env.restore(); } }); + + it("resolves inline Claude MCP paths from the plugin root and expands CLAUDE_PLUGIN_ROOT", async () => { + const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); + try { + const homeDir = await createTempDir("openclaw-bundle-inline-placeholder-home-"); + const workspaceDir = await createTempDir("openclaw-bundle-inline-placeholder-workspace-"); + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_STATE_DIR; + + const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "inline-claude"); + await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + await fs.writeFile( + path.join(pluginRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify( + { + name: "inline-claude", + mcpServers: { + inlineProbe: { + command: "${CLAUDE_PLUGIN_ROOT}/bin/server.sh", + args: ["${CLAUDE_PLUGIN_ROOT}/servers/probe.mjs", "./local-probe.mjs"], + cwd: "${CLAUDE_PLUGIN_ROOT}", + env: { + PLUGIN_ROOT: "${CLAUDE_PLUGIN_ROOT}", + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const loaded = loadEnabledBundleMcpConfig({ + workspaceDir, + cfg: { + plugins: { + entries: { + "inline-claude": { enabled: true }, + }, + }, + }, + }); + const resolvedPluginRoot = await fs.realpath(pluginRoot); + + expect(loaded.diagnostics).toEqual([]); + expect(loaded.config.mcpServers.inlineProbe).toEqual({ + command: path.join(resolvedPluginRoot, "bin", "server.sh"), + args: [ + path.join(resolvedPluginRoot, "servers", "probe.mjs"), + path.join(resolvedPluginRoot, "local-probe.mjs"), + ], + cwd: resolvedPluginRoot, + env: { + PLUGIN_ROOT: resolvedPluginRoot, + }, + }); + } finally { + env.restore(); + } + }); }); diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index 62c10e59156..29bd2b3a6c9 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -28,12 +28,18 @@ export type EnabledBundleMcpConfigResult = { config: BundleMcpConfig; diagnostics: BundleMcpDiagnostic[]; }; +export type BundleMcpRuntimeSupport = { + hasSupportedStdioServer: boolean; + unsupportedServerNames: string[]; + diagnostics: string[]; +}; const MANIFEST_PATH_BY_FORMAT: Record = { claude: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, codex: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, cursor: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, }; +const CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"; function normalizePathList(value: unknown): string[] { if (typeof value === "string") { @@ -131,36 +137,68 @@ function isExplicitRelativePath(value: string): boolean { return value === "." || value === ".." || value.startsWith("./") || value.startsWith("../"); } +function expandBundleRootPlaceholders(value: string, rootDir: string): string { + if (!value.includes(CLAUDE_PLUGIN_ROOT_PLACEHOLDER)) { + return value; + } + return value.split(CLAUDE_PLUGIN_ROOT_PLACEHOLDER).join(rootDir); +} + function absolutizeBundleMcpServer(params: { + rootDir: string; baseDir: string; server: BundleMcpServerConfig; }): BundleMcpServerConfig { const next: BundleMcpServerConfig = { ...params.server }; + if (typeof next.cwd !== "string" && typeof next.workingDirectory !== "string") { + next.cwd = params.baseDir; + } + const command = next.command; - if (typeof command === "string" && isExplicitRelativePath(command)) { - next.command = path.resolve(params.baseDir, command); + if (typeof command === "string") { + const expanded = expandBundleRootPlaceholders(command, params.rootDir); + next.command = isExplicitRelativePath(expanded) + ? path.resolve(params.baseDir, expanded) + : expanded; } const cwd = next.cwd; - if (typeof cwd === "string" && !path.isAbsolute(cwd)) { - next.cwd = path.resolve(params.baseDir, cwd); + if (typeof cwd === "string") { + const expanded = expandBundleRootPlaceholders(cwd, params.rootDir); + next.cwd = path.isAbsolute(expanded) ? expanded : path.resolve(params.baseDir, expanded); } const workingDirectory = next.workingDirectory; - if (typeof workingDirectory === "string" && !path.isAbsolute(workingDirectory)) { - next.workingDirectory = path.resolve(params.baseDir, workingDirectory); + if (typeof workingDirectory === "string") { + const expanded = expandBundleRootPlaceholders(workingDirectory, params.rootDir); + next.workingDirectory = path.isAbsolute(expanded) + ? expanded + : path.resolve(params.baseDir, expanded); } if (Array.isArray(next.args)) { next.args = next.args.map((entry) => { - if (typeof entry !== "string" || !isExplicitRelativePath(entry)) { + if (typeof entry !== "string") { return entry; } - return path.resolve(params.baseDir, entry); + const expanded = expandBundleRootPlaceholders(entry, params.rootDir); + if (!isExplicitRelativePath(expanded)) { + return expanded; + } + return path.resolve(params.baseDir, expanded); }); } + if (isRecord(next.env)) { + next.env = Object.fromEntries( + Object.entries(next.env).map(([key, value]) => [ + key, + typeof value === "string" ? expandBundleRootPlaceholders(value, params.rootDir) : value, + ]), + ); + } + return next; } @@ -190,7 +228,7 @@ function loadBundleFileBackedMcpConfig(params: { mcpServers: Object.fromEntries( Object.entries(servers).map(([serverName, server]) => [ serverName, - absolutizeBundleMcpServer({ baseDir, server }), + absolutizeBundleMcpServer({ rootDir: params.rootDir, baseDir, server }), ]), ), }; @@ -211,7 +249,7 @@ function loadBundleInlineMcpConfig(params: { mcpServers: Object.fromEntries( Object.entries(servers).map(([serverName, server]) => [ serverName, - absolutizeBundleMcpServer({ baseDir: params.baseDir, server }), + absolutizeBundleMcpServer({ rootDir: params.baseDir, baseDir: params.baseDir, server }), ]), ), }; @@ -252,13 +290,35 @@ function loadBundleMcpConfig(params: { merged, loadBundleInlineMcpConfig({ raw: manifestLoaded.raw, - baseDir: path.dirname(path.join(params.rootDir, manifestRelativePath)), + baseDir: params.rootDir, }), ) as BundleMcpConfig; return { config: merged, diagnostics: [] }; } +export function inspectBundleMcpRuntimeSupport(params: { + pluginId: string; + rootDir: string; + bundleFormat: PluginBundleFormat; +}): BundleMcpRuntimeSupport { + const loaded = loadBundleMcpConfig(params); + const unsupportedServerNames: string[] = []; + let hasSupportedStdioServer = false; + for (const [serverName, server] of Object.entries(loaded.config.mcpServers)) { + if (typeof server.command === "string" && server.command.trim().length > 0) { + hasSupportedStdioServer = true; + continue; + } + unsupportedServerNames.push(serverName); + } + return { + hasSupportedStdioServer, + unsupportedServerNames, + diagnostics: loaded.diagnostics, + }; +} + export function loadEnabledBundleMcpConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 808ba4c8cb7..a1e25c0ea3e 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -420,6 +420,116 @@ describe("bundle plugins", () => { ).toBe(false); }); + it("treats bundle MCP as a supported bundle surface", () => { + const workspaceDir = makeTempDir(); + const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-mcp"); + mkdirSafe(path.join(bundleRoot, ".claude-plugin")); + fs.writeFileSync( + path.join(bundleRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "Claude MCP", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + probe: { + command: "node", + args: ["./probe.mjs"], + }, + }, + }), + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + workspaceDir, + config: { + plugins: { + entries: { + "claude-mcp": { + enabled: true, + }, + }, + }, + }, + cache: false, + }); + + const plugin = registry.plugins.find((entry) => entry.id === "claude-mcp"); + expect(plugin?.status).toBe("loaded"); + expect(plugin?.bundleFormat).toBe("claude"); + expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["mcpServers"])); + expect( + registry.diagnostics.some( + (diag) => + diag.pluginId === "claude-mcp" && + diag.message.includes("bundle capability detected but not wired"), + ), + ).toBe(false); + }); + + it("warns when bundle MCP only declares unsupported non-stdio transports", () => { + useNoBundledPlugins(); + const workspaceDir = makeTempDir(); + const stateDir = makeTempDir(); + const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-mcp-url"); + fs.mkdirSync(path.join(bundleRoot, ".claude-plugin"), { recursive: true }); + fs.writeFileSync( + path.join(bundleRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "Claude MCP URL", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + remoteProbe: { + url: "http://127.0.0.1:8787/mcp", + }, + }, + }), + "utf-8", + ); + + const registry = withEnv( + { + OPENCLAW_HOME: stateDir, + OPENCLAW_STATE_DIR: stateDir, + }, + () => + loadOpenClawPlugins({ + workspaceDir, + config: { + plugins: { + entries: { + "claude-mcp-url": { + enabled: true, + }, + }, + }, + }, + cache: false, + }), + ); + + const plugin = registry.plugins.find((entry) => entry.id === "claude-mcp-url"); + expect(plugin?.status).toBe("loaded"); + expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["mcpServers"])); + expect( + registry.diagnostics.some( + (diag) => + diag.pluginId === "claude-mcp-url" && + diag.message.includes("stdio only today") && + diag.message.includes("remoteProbe"), + ), + ).toBe(true); + }); + it("treats Cursor command roots as supported bundle skill surfaces", () => { useNoBundledPlugins(); const workspaceDir = makeTempDir(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 873fff6b9bf..86273793006 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -11,6 +11,7 @@ import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; +import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; import { clearPluginCommands } from "./commands.js"; import { applyTestPluginDefaults, @@ -1099,6 +1100,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter( (capability) => capability !== "skills" && + capability !== "mcpServers" && capability !== "settings" && !( capability === "commands" && @@ -1114,6 +1116,36 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi message: `bundle capability detected but not wired into OpenClaw yet: ${capability}`, }); } + if ( + enableState.enabled && + record.rootDir && + record.bundleFormat && + (record.bundleCapabilities ?? []).includes("mcpServers") + ) { + const runtimeSupport = inspectBundleMcpRuntimeSupport({ + pluginId: record.id, + rootDir: record.rootDir, + bundleFormat: record.bundleFormat, + }); + for (const message of runtimeSupport.diagnostics) { + registry.diagnostics.push({ + level: "warn", + pluginId: record.id, + source: record.source, + message, + }); + } + if (runtimeSupport.unsupportedServerNames.length > 0) { + registry.diagnostics.push({ + level: "warn", + pluginId: record.id, + source: record.source, + message: + "bundle MCP servers use unsupported transports or incomplete configs " + + `(stdio only today): ${runtimeSupport.unsupportedServerNames.join(", ")}`, + }); + } + } registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); continue; From ad7924b0ac14d8a08134bf857f9b2a7155a778cd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 21:47:16 -0700 Subject: [PATCH 097/128] Agents: add OpenAI attribution headers (#48737) --- src/agents/openai-ws-connection.test.ts | 18 +++ src/agents/openai-ws-connection.ts | 22 +++- .../extra-params.openai.test.ts | 110 ++++++++++++++++++ src/agents/pi-embedded-runner/extra-params.ts | 10 +- .../openai-stream-wrappers.ts | 54 +++++++++ src/agents/provider-attribution.test.ts | 41 +++++-- src/agents/provider-attribution.ts | 48 +++++++- 7 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 src/agents/pi-embedded-runner/extra-params.openai.test.ts diff --git a/src/agents/openai-ws-connection.test.ts b/src/agents/openai-ws-connection.test.ts index 2a7b95f7eb9..4f3f2d4e706 100644 --- a/src/agents/openai-ws-connection.test.ts +++ b/src/agents/openai-ws-connection.test.ts @@ -167,6 +167,8 @@ function buildManager(opts?: ConstructorParameters + new MockWebSocket(url, options as Record) as never, ...opts, }); } @@ -232,6 +234,22 @@ describe("OpenAIWebSocketManager", () => { await connectPromise; }); + it("adds OpenClaw attribution headers on the native OpenAI websocket", async () => { + const manager = buildManager(); + const connectPromise = manager.connect("sk-test-key"); + + const sock = lastSocket(); + expect(sock.options).toMatchObject({ + headers: expect.objectContaining({ + originator: "openclaw", + "User-Agent": expect.stringMatching(/^openclaw\//), + }), + }); + + sock.simulateOpen(); + await connectPromise; + }); + it("resolves when the connection opens", async () => { const manager = buildManager(); const connectPromise = manager.connect("sk-test"); diff --git a/src/agents/openai-ws-connection.ts b/src/agents/openai-ws-connection.ts index 2d9c6ffe7e6..1765eb00172 100644 --- a/src/agents/openai-ws-connection.ts +++ b/src/agents/openai-ws-connection.ts @@ -15,6 +15,7 @@ import { EventEmitter } from "node:events"; import WebSocket from "ws"; +import { resolveProviderAttributionHeaders } from "./provider-attribution.js"; // ───────────────────────────────────────────────────────────────────────────── // WebSocket Event Types (Server → Client) @@ -251,6 +252,14 @@ const MAX_RETRIES = 5; /** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s */ const BACKOFF_DELAYS_MS = [1000, 2000, 4000, 8000, 16000] as const; +function isOpenAIPublicWebSocketUrl(url: string): boolean { + try { + return new URL(url).hostname.toLowerCase() === "api.openai.com"; + } catch { + return url.toLowerCase().includes("api.openai.com"); + } +} + export interface OpenAIWebSocketManagerOptions { /** Override the default WebSocket URL (useful for testing) */ url?: string; @@ -258,6 +267,8 @@ export interface OpenAIWebSocketManagerOptions { maxRetries?: number; /** Custom backoff delays in ms (default: [1000, 2000, 4000, 8000, 16000]) */ backoffDelaysMs?: readonly number[]; + /** Custom socket factory for tests. */ + socketFactory?: (url: string, options: ConstructorParameters[1]) => WebSocket; } type InternalEvents = { @@ -297,12 +308,18 @@ export class OpenAIWebSocketManager extends EventEmitter { private readonly wsUrl: string; private readonly maxRetries: number; private readonly backoffDelaysMs: readonly number[]; + private readonly socketFactory: ( + url: string, + options: ConstructorParameters[1], + ) => WebSocket; constructor(options: OpenAIWebSocketManagerOptions = {}) { super(); this.wsUrl = options.url ?? OPENAI_WS_URL; this.maxRetries = options.maxRetries ?? MAX_RETRIES; this.backoffDelaysMs = options.backoffDelaysMs ?? BACKOFF_DELAYS_MS; + this.socketFactory = + options.socketFactory ?? ((url, socketOptions) => new WebSocket(url, socketOptions)); } // ─── Public API ──────────────────────────────────────────────────────────── @@ -382,10 +399,13 @@ export class OpenAIWebSocketManager extends EventEmitter { return; } - const socket = new WebSocket(this.wsUrl, { + const socket = this.socketFactory(this.wsUrl, { headers: { Authorization: `Bearer ${this.apiKey}`, "OpenAI-Beta": "responses-websocket=v1", + ...(isOpenAIPublicWebSocketUrl(this.wsUrl) + ? resolveProviderAttributionHeaders("openai") + : undefined), }, }); diff --git a/src/agents/pi-embedded-runner/extra-params.openai.test.ts b/src/agents/pi-embedded-runner/extra-params.openai.test.ts new file mode 100644 index 00000000000..92e26c95ee0 --- /dev/null +++ b/src/agents/pi-embedded-runner/extra-params.openai.test.ts @@ -0,0 +1,110 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import type { Context, Model } from "@mariozechner/pi-ai"; +import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../../test-utils/env.js"; +import { applyExtraParamsToAgent } from "./extra-params.js"; + +type CapturedCall = { + headers?: Record; +}; + +function applyAndCapture(params: { + provider: string; + modelId: string; + baseUrl?: string; + callerHeaders?: Record; +}): CapturedCall { + const captured: CapturedCall = {}; + const baseStreamFn: StreamFn = (model, _context, options) => { + captured.headers = options?.headers; + options?.onPayload?.({}, model); + return createAssistantMessageEventStream(); + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, params.provider, params.modelId); + + const model = { + api: "openai-responses", + provider: params.provider, + id: params.modelId, + baseUrl: params.baseUrl, + } as Model<"openai-responses">; + const context: Context = { messages: [] }; + + void agent.streamFn?.(model, context, { headers: params.callerHeaders }); + + return captured; +} + +describe("extra-params: OpenAI attribution", () => { + const envSnapshot = captureEnv(["OPENCLAW_VERSION"]); + + afterEach(() => { + envSnapshot.restore(); + }); + + it("injects originator and release-based user agent for native OpenAI", () => { + process.env.OPENCLAW_VERSION = "2026.3.14"; + + const { headers } = applyAndCapture({ + provider: "openai", + modelId: "gpt-5.4", + baseUrl: "https://api.openai.com/v1", + }); + + expect(headers).toEqual({ + originator: "openclaw", + "User-Agent": "openclaw/2026.3.14", + }); + }); + + it("overrides caller-supplied OpenAI attribution headers", () => { + process.env.OPENCLAW_VERSION = "2026.3.14"; + + const { headers } = applyAndCapture({ + provider: "openai", + modelId: "gpt-5.4", + baseUrl: "https://api.openai.com/v1", + callerHeaders: { + originator: "spoofed", + "User-Agent": "spoofed/0.0.0", + "X-Custom": "1", + }, + }); + + expect(headers).toEqual({ + originator: "openclaw", + "User-Agent": "openclaw/2026.3.14", + "X-Custom": "1", + }); + }); + + it("does not inject attribution on non-native OpenAI-compatible base URLs", () => { + process.env.OPENCLAW_VERSION = "2026.3.14"; + + const { headers } = applyAndCapture({ + provider: "openai", + modelId: "gpt-5.4", + baseUrl: "https://proxy.example.com/v1", + }); + + expect(headers).toBeUndefined(); + }); + + it("injects attribution for ChatGPT-backed OpenAI Codex traffic", () => { + process.env.OPENCLAW_VERSION = "2026.3.14"; + + const { headers } = applyAndCapture({ + provider: "openai-codex", + modelId: "gpt-5.4", + baseUrl: "https://chatgpt.com/backend-api", + }); + + expect(headers).toEqual({ + originator: "openclaw", + "User-Agent": "openclaw/2026.3.14", + }); + }); +}); diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 7a73280802c..e3aa8b1dbcc 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -26,6 +26,7 @@ import { shouldApplySiliconFlowThinkingOffCompat, } from "./moonshot-stream-wrappers.js"; import { + createOpenAIAttributionHeadersWrapper, createOpenAIDefaultTransportWrapper, createOpenAIFastModeWrapper, createOpenAIResponsesContextManagementWrapper, @@ -303,9 +304,12 @@ export function applyExtraParamsToAgent( }, }) ?? merged; - if (provider === "openai") { - // Default OpenAI Responses to WebSocket-first with transparent SSE fallback. - agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn); + if (provider === "openai" || provider === "openai-codex") { + if (provider === "openai") { + // Default OpenAI Responses to WebSocket-first with transparent SSE fallback. + agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn); + } + agent.streamFn = createOpenAIAttributionHeadersWrapper(agent.streamFn); } const wrappedStreamFn = createStreamFnWithExtraParams( agent.streamFn, diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index 8542f329cbe..4131a33f08d 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -1,6 +1,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { SimpleStreamOptions } from "@mariozechner/pi-ai"; import { streamSimple } from "@mariozechner/pi-ai"; +import { resolveProviderAttributionHeaders } from "../provider-attribution.js"; import { log } from "./logger.js"; import { streamWithPayloadPatch } from "./stream-payload-utils.js"; @@ -42,6 +43,40 @@ function isOpenAIPublicApiBaseUrl(baseUrl: unknown): boolean { } } +function isOpenAICodexBaseUrl(baseUrl: unknown): boolean { + if (typeof baseUrl !== "string" || !baseUrl.trim()) { + return false; + } + + try { + return new URL(baseUrl).hostname.toLowerCase() === "chatgpt.com"; + } catch { + return baseUrl.toLowerCase().includes("chatgpt.com"); + } +} + +function shouldApplyOpenAIAttributionHeaders(model: { + api?: unknown; + provider?: unknown; + baseUrl?: unknown; +}): "openai" | "openai-codex" | undefined { + if ( + model.provider === "openai" && + (model.api === "openai-completions" || model.api === "openai-responses") && + isOpenAIPublicApiBaseUrl(model.baseUrl) + ) { + return "openai"; + } + if ( + model.provider === "openai-codex" && + (model.api === "openai-codex-responses" || model.api === "openai-responses") && + isOpenAICodexBaseUrl(model.baseUrl) + ) { + return "openai-codex"; + } + return undefined; +} + function shouldForceResponsesStore(model: { api?: unknown; provider?: unknown; @@ -357,3 +392,22 @@ export function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | und return underlying(model, context, mergedOptions); }; } + +export function createOpenAIAttributionHeadersWrapper( + baseStreamFn: StreamFn | undefined, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const attributionProvider = shouldApplyOpenAIAttributionHeaders(model); + if (!attributionProvider) { + return underlying(model, context, options); + } + return underlying(model, context, { + ...options, + headers: { + ...options?.headers, + ...resolveProviderAttributionHeaders(attributionProvider), + }, + }); + }; +} diff --git a/src/agents/provider-attribution.test.ts b/src/agents/provider-attribution.test.ts index 693e165ba21..04c7d040b17 100644 --- a/src/agents/provider-attribution.test.ts +++ b/src/agents/provider-attribution.test.ts @@ -52,18 +52,44 @@ describe("provider attribution", () => { }); }); - it("tracks SDK-hook-only providers without enabling them", () => { + it("returns a hidden-spec OpenAI attribution policy", () => { expect(resolveProviderAttributionPolicy("openai", { OPENCLAW_VERSION: "2026.3.14" })).toEqual({ provider: "openai", - enabledByDefault: false, - verification: "vendor-sdk-hook-only", - hook: "default-headers", + enabledByDefault: true, + verification: "vendor-hidden-api-spec", + hook: "request-headers", reviewNote: - "OpenAI JS SDK exposes defaultHeaders, but public app attribution support is not yet verified.", + "OpenAI native traffic supports hidden originator/User-Agent attribution. Verified against the Codex wire contract.", product: "OpenClaw", version: "2026.3.14", + headers: { + originator: "openclaw", + "User-Agent": "openclaw/2026.3.14", + }, + }); + expect(resolveProviderAttributionHeaders("openai", { OPENCLAW_VERSION: "2026.3.14" })).toEqual({ + originator: "openclaw", + "User-Agent": "openclaw/2026.3.14", + }); + }); + + it("returns a hidden-spec OpenAI Codex attribution policy", () => { + expect( + resolveProviderAttributionPolicy("openai-codex", { OPENCLAW_VERSION: "2026.3.14" }), + ).toEqual({ + provider: "openai-codex", + enabledByDefault: true, + verification: "vendor-hidden-api-spec", + hook: "request-headers", + reviewNote: + "OpenAI Codex ChatGPT-backed traffic supports the same hidden originator/User-Agent attribution contract.", + product: "OpenClaw", + version: "2026.3.14", + headers: { + originator: "openclaw", + "User-Agent": "openclaw/2026.3.14", + }, }); - expect(resolveProviderAttributionHeaders("openai")).toBeUndefined(); }); it("lists the current attribution support matrix", () => { @@ -76,11 +102,12 @@ describe("provider attribution", () => { ]), ).toEqual([ ["openrouter", true, "vendor-documented", "request-headers"], + ["openai", true, "vendor-hidden-api-spec", "request-headers"], + ["openai-codex", true, "vendor-hidden-api-spec", "request-headers"], ["anthropic", false, "vendor-sdk-hook-only", "default-headers"], ["google", false, "vendor-sdk-hook-only", "user-agent-extra"], ["groq", false, "vendor-sdk-hook-only", "default-headers"], ["mistral", false, "vendor-sdk-hook-only", "custom-user-agent"], - ["openai", false, "vendor-sdk-hook-only", "default-headers"], ["together", false, "vendor-sdk-hook-only", "default-headers"], ]); }); diff --git a/src/agents/provider-attribution.ts b/src/agents/provider-attribution.ts index 52fe5c8d4c7..f1111a8e5bd 100644 --- a/src/agents/provider-attribution.ts +++ b/src/agents/provider-attribution.ts @@ -4,6 +4,7 @@ import { normalizeProviderId } from "./model-selection.js"; export type ProviderAttributionVerification = | "vendor-documented" + | "vendor-hidden-api-spec" | "vendor-sdk-hook-only" | "internal-runtime"; @@ -28,6 +29,7 @@ export type ProviderAttributionPolicy = { export type ProviderAttributionIdentity = Pick; const OPENCLAW_ATTRIBUTION_PRODUCT = "OpenClaw"; +const OPENCLAW_ATTRIBUTION_ORIGINATOR = "openclaw"; export function resolveProviderAttributionIdentity( env: RuntimeVersionEnv = process.env as RuntimeVersionEnv, @@ -58,6 +60,44 @@ function buildOpenRouterAttributionPolicy( }; } +function buildOpenAIAttributionPolicy( + env: RuntimeVersionEnv = process.env as RuntimeVersionEnv, +): ProviderAttributionPolicy { + const identity = resolveProviderAttributionIdentity(env); + return { + provider: "openai", + enabledByDefault: true, + verification: "vendor-hidden-api-spec", + hook: "request-headers", + reviewNote: + "OpenAI native traffic supports hidden originator/User-Agent attribution. Verified against the Codex wire contract.", + ...identity, + headers: { + originator: OPENCLAW_ATTRIBUTION_ORIGINATOR, + "User-Agent": `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${identity.version}`, + }, + }; +} + +function buildOpenAICodexAttributionPolicy( + env: RuntimeVersionEnv = process.env as RuntimeVersionEnv, +): ProviderAttributionPolicy { + const identity = resolveProviderAttributionIdentity(env); + return { + provider: "openai-codex", + enabledByDefault: true, + verification: "vendor-hidden-api-spec", + hook: "request-headers", + reviewNote: + "OpenAI Codex ChatGPT-backed traffic supports the same hidden originator/User-Agent attribution contract.", + ...identity, + headers: { + originator: OPENCLAW_ATTRIBUTION_ORIGINATOR, + "User-Agent": `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${identity.version}`, + }, + }; +} + function buildSdkHookOnlyPolicy( provider: string, hook: ProviderAttributionHook, @@ -79,6 +119,8 @@ export function listProviderAttributionPolicies( ): ProviderAttributionPolicy[] { return [ buildOpenRouterAttributionPolicy(env), + buildOpenAIAttributionPolicy(env), + buildOpenAICodexAttributionPolicy(env), buildSdkHookOnlyPolicy( "anthropic", "default-headers", @@ -103,12 +145,6 @@ export function listProviderAttributionPolicies( "Mistral JS SDK exposes a custom userAgent option, but app attribution is not yet verified.", env, ), - buildSdkHookOnlyPolicy( - "openai", - "default-headers", - "OpenAI JS SDK exposes defaultHeaders, but public app attribution support is not yet verified.", - env, - ), buildSdkHookOnlyPolicy( "together", "default-headers", From dde89d2a834af2ccf9429d5ee9cbcaeee18a559d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:47:21 -0700 Subject: [PATCH 098/128] refactor: isolate provider sdk auth and model helpers --- extensions/whatsapp/src/channel.ts | 4 - src/channels/plugins/setup-wizard-helpers.ts | 8 +- src/commands/auth-choice.api-key.ts | 53 +- src/commands/auth-choice.apply-helpers.ts | 464 +--------------- .../auth-choice.apply.plugin-provider.ts | 8 +- src/commands/auth-token.ts | 46 +- src/commands/google-gemini-model-default.ts | 15 +- src/commands/model-allowlist.ts | 42 +- src/commands/model-default.ts | 49 +- src/commands/model-picker.ts | 30 +- src/commands/oauth-flow.ts | 54 +- src/commands/oauth-tls-preflight.ts | 165 +----- .../local/auth-choice.plugin-providers.ts | 3 +- src/commands/onboard-types.ts | 3 +- src/commands/openai-codex-oauth.ts | 66 +-- src/commands/openai-model-default.ts | 52 +- src/commands/opencode-go-model-default.ts | 15 +- src/commands/opencode-zen-model-default.ts | 23 +- src/commands/self-hosted-provider-setup.ts | 12 +- src/plugin-sdk/provider-auth.ts | 11 +- src/plugin-sdk/provider-models.ts | 8 +- src/plugin-sdk/provider-onboard.ts | 2 +- src/plugins/provider-api-key-auth.runtime.ts | 9 +- src/plugins/provider-auth-helpers.ts | 2 +- src/plugins/provider-auth-input.ts | 496 ++++++++++++++++++ src/plugins/provider-auth-token.ts | 38 ++ src/plugins/provider-auth-types.ts | 1 + src/plugins/provider-model-allowlist.ts | 41 ++ src/plugins/provider-model-defaults.ts | 81 +++ src/plugins/provider-model-primary.ts | 72 +++ src/plugins/provider-oauth-flow.ts | 53 ++ .../provider-openai-codex-oauth-tls.ts | 164 ++++++ src/plugins/provider-openai-codex-oauth.ts | 65 +++ src/plugins/types.ts | 17 +- src/wizard/setup.gateway-config.ts | 8 +- 35 files changed, 1118 insertions(+), 1062 deletions(-) create mode 100644 src/plugins/provider-auth-input.ts create mode 100644 src/plugins/provider-auth-token.ts create mode 100644 src/plugins/provider-auth-types.ts create mode 100644 src/plugins/provider-model-allowlist.ts create mode 100644 src/plugins/provider-model-defaults.ts create mode 100644 src/plugins/provider-model-primary.ts create mode 100644 src/plugins/provider-oauth-flow.ts create mode 100644 src/plugins/provider-openai-codex-oauth-tls.ts create mode 100644 src/plugins/provider-openai-codex-oauth.ts diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 63d222ba1ed..dda6215c27f 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -41,10 +41,6 @@ import { collectWhatsAppStatusIssues } from "./status-issues.js"; const meta = getChatChannelMeta("whatsapp"); -async function loadWhatsAppChannelRuntime() { - return await import("./channel.runtime.js"); -} - function normalizeWhatsAppPayloadText(text: string | undefined): string { return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); } diff --git a/src/channels/plugins/setup-wizard-helpers.ts b/src/channels/plugins/setup-wizard-helpers.ts index de513f64d27..c80a00dd324 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -1,10 +1,10 @@ -import { - promptSecretRefForSetup, - resolveSecretInputModeForEnvSelection, -} from "../../commands/auth-choice.apply-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { DmPolicy, GroupPolicy } from "../../config/types.js"; import type { SecretInput } from "../../config/types.secrets.js"; +import { + promptSecretRefForSetup, + resolveSecretInputModeForEnvSelection, +} from "../../plugins/provider-auth-input.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import { diff --git a/src/commands/auth-choice.api-key.ts b/src/commands/auth-choice.api-key.ts index 59a7ca08e6f..ae5e716a46f 100644 --- a/src/commands/auth-choice.api-key.ts +++ b/src/commands/auth-choice.api-key.ts @@ -1,48 +1,5 @@ -const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 }; - -export function normalizeApiKeyInput(raw: string): string { - const trimmed = String(raw ?? "").trim(); - if (!trimmed) { - return ""; - } - - // Handle shell-style assignments: export KEY="value" or KEY=value - const assignmentMatch = trimmed.match(/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/); - const valuePart = assignmentMatch ? assignmentMatch[1].trim() : trimmed; - - const unquoted = - valuePart.length >= 2 && - ((valuePart.startsWith('"') && valuePart.endsWith('"')) || - (valuePart.startsWith("'") && valuePart.endsWith("'")) || - (valuePart.startsWith("`") && valuePart.endsWith("`"))) - ? valuePart.slice(1, -1) - : valuePart; - - const withoutSemicolon = unquoted.endsWith(";") ? unquoted.slice(0, -1) : unquoted; - - return withoutSemicolon.trim(); -} - -export const validateApiKeyInput = (value: string) => - normalizeApiKeyInput(value).length > 0 ? undefined : "Required"; - -export function formatApiKeyPreview( - raw: string, - opts: { head?: number; tail?: number } = {}, -): string { - const trimmed = raw.trim(); - if (!trimmed) { - return "…"; - } - const head = opts.head ?? DEFAULT_KEY_PREVIEW.head; - const tail = opts.tail ?? DEFAULT_KEY_PREVIEW.tail; - if (trimmed.length <= head + tail) { - const shortHead = Math.min(2, trimmed.length); - const shortTail = Math.min(2, trimmed.length - shortHead); - if (shortTail <= 0) { - return `${trimmed.slice(0, shortHead)}…`; - } - return `${trimmed.slice(0, shortHead)}…${trimmed.slice(-shortTail)}`; - } - return `${trimmed.slice(0, head)}…${trimmed.slice(-tail)}`; -} +export { + formatApiKeyPreview, + normalizeApiKeyInput, + validateApiKeyInput, +} from "../plugins/provider-auth-input.js"; diff --git a/src/commands/auth-choice.apply-helpers.ts b/src/commands/auth-choice.apply-helpers.ts index 7029dd081c3..b123f50f99c 100644 --- a/src/commands/auth-choice.apply-helpers.ts +++ b/src/commands/auth-choice.apply-helpers.ts @@ -1,280 +1,19 @@ -import { resolveEnvApiKey } from "../agents/model-auth.js"; -import type { OpenClawConfig } from "../config/types.js"; -import { - isValidEnvSecretRefId, - type SecretInput, - type SecretRef, -} from "../config/types.secrets.js"; -import { encodeJsonPointerToken } from "../secrets/json-pointer.js"; -import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; -import { - formatExecSecretRefIdValidationMessage, - isValidExecSecretRefId, - isValidFileSecretRefId, - resolveDefaultSecretProviderAlias, -} from "../secrets/ref-contract.js"; -import { resolveSecretRefString } from "../secrets/resolve.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; -import { formatApiKeyPreview } from "./auth-choice.api-key.js"; import type { ApplyAuthChoiceParams } from "./auth-choice.apply.js"; import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; -import type { SecretInputMode } from "./onboard-types.js"; -const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/; - -type SecretRefChoice = "env" | "provider"; // pragma: allowlist secret - -export type SecretInputModePromptCopy = { - modeMessage?: string; - plaintextLabel?: string; - plaintextHint?: string; - refLabel?: string; - refHint?: string; -}; - -export type SecretRefSetupPromptCopy = { - sourceMessage?: string; - envVarMessage?: string; - envVarPlaceholder?: string; - envVarFormatError?: string; - envVarMissingError?: (envVar: string) => string; - noProvidersMessage?: string; - envValidatedMessage?: (envVar: string) => string; - providerValidatedMessage?: (provider: string, id: string, source: "file" | "exec") => string; -}; - -function formatErrorMessage(error: unknown): string { - if (error instanceof Error && typeof error.message === "string" && error.message.trim()) { - return error.message; - } - return String(error); -} - -function extractEnvVarFromSourceLabel(source: string): string | undefined { - const match = ENV_SOURCE_LABEL_RE.exec(source.trim()); - return match?.[1]; -} - -function resolveDefaultProviderEnvVar(provider: string): string | undefined { - const envVars = PROVIDER_ENV_VARS[provider]; - return envVars?.find((candidate) => candidate.trim().length > 0); -} - -function resolveDefaultFilePointerId(provider: string): string { - return `/providers/${encodeJsonPointerToken(provider)}/apiKey`; -} - -function resolveRefFallbackInput(params: { - config: OpenClawConfig; - provider: string; - preferredEnvVar?: string; -}): { ref: SecretRef; resolvedValue: string } { - const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider); - if (!fallbackEnvVar) { - throw new Error( - `No default environment variable mapping found for provider "${params.provider}". Set a provider-specific env var, or re-run setup in an interactive terminal to configure a ref.`, - ); - } - const value = process.env[fallbackEnvVar]?.trim(); - if (!value) { - throw new Error( - `Environment variable "${fallbackEnvVar}" is required for --secret-input-mode ref in non-interactive setup.`, - ); - } - return { - ref: { - source: "env", - provider: resolveDefaultSecretProviderAlias(params.config, "env", { - preferFirstProviderForSource: true, - }), - id: fallbackEnvVar, - }, - resolvedValue: value, - }; -} - -export async function promptSecretRefForSetup(params: { - provider: string; - config: OpenClawConfig; - prompter: WizardPrompter; - preferredEnvVar?: string; - copy?: SecretRefSetupPromptCopy; -}): Promise<{ ref: SecretRef; resolvedValue: string }> { - const defaultEnvVar = - params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? ""; - const defaultFilePointer = resolveDefaultFilePointerId(params.provider); - let sourceChoice: SecretRefChoice = "env"; // pragma: allowlist secret - - while (true) { - const sourceRaw: SecretRefChoice = await params.prompter.select({ - message: params.copy?.sourceMessage ?? "Where is this API key stored?", - initialValue: sourceChoice, - options: [ - { - value: "env", - label: "Environment variable", - hint: "Reference a variable from your runtime environment", - }, - { - value: "provider", - label: "Configured secret provider", - hint: "Use a configured file or exec secret provider", - }, - ], - }); - const source: SecretRefChoice = sourceRaw === "provider" ? "provider" : "env"; - sourceChoice = source; - - if (source === "env") { - const envVarRaw = await params.prompter.text({ - message: params.copy?.envVarMessage ?? "Environment variable name", - initialValue: defaultEnvVar || undefined, - placeholder: params.copy?.envVarPlaceholder ?? "OPENAI_API_KEY", - validate: (value) => { - const candidate = value.trim(); - if (!isValidEnvSecretRefId(candidate)) { - return ( - params.copy?.envVarFormatError ?? - 'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).' - ); - } - if (!process.env[candidate]?.trim()) { - return ( - params.copy?.envVarMissingError?.(candidate) ?? - `Environment variable "${candidate}" is missing or empty in this session.` - ); - } - return undefined; - }, - }); - const envCandidate = String(envVarRaw ?? "").trim(); - const envVar = - envCandidate && isValidEnvSecretRefId(envCandidate) ? envCandidate : defaultEnvVar; - if (!envVar) { - throw new Error( - `No valid environment variable name provided for provider "${params.provider}".`, - ); - } - const ref: SecretRef = { - source: "env", - provider: resolveDefaultSecretProviderAlias(params.config, "env", { - preferFirstProviderForSource: true, - }), - id: envVar, - }; - const resolvedValue = await resolveSecretRefString(ref, { - config: params.config, - env: process.env, - }); - await params.prompter.note( - params.copy?.envValidatedMessage?.(envVar) ?? - `Validated environment variable ${envVar}. OpenClaw will store a reference, not the key value.`, - "Reference validated", - ); - return { ref, resolvedValue }; - } - - const externalProviders = Object.entries(params.config.secrets?.providers ?? {}).filter( - ([, provider]) => provider?.source === "file" || provider?.source === "exec", - ); - if (externalProviders.length === 0) { - await params.prompter.note( - params.copy?.noProvidersMessage ?? - "No file/exec secret providers are configured yet. Add one under secrets.providers, or select Environment variable.", - "No providers configured", - ); - continue; - } - const defaultProvider = resolveDefaultSecretProviderAlias(params.config, "file", { - preferFirstProviderForSource: true, - }); - const selectedProvider = await params.prompter.select({ - message: "Select secret provider", - initialValue: - externalProviders.find(([providerName]) => providerName === defaultProvider)?.[0] ?? - externalProviders[0]?.[0], - options: externalProviders.map(([providerName, provider]) => ({ - value: providerName, - label: providerName, - hint: provider?.source === "exec" ? "Exec provider" : "File provider", - })), - }); - const providerEntry = params.config.secrets?.providers?.[selectedProvider]; - if (!providerEntry || (providerEntry.source !== "file" && providerEntry.source !== "exec")) { - await params.prompter.note( - `Provider "${selectedProvider}" is not a file/exec provider.`, - "Invalid provider", - ); - continue; - } - const idPrompt = - providerEntry.source === "file" - ? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)" - : "Secret id for the exec provider"; - const idDefault = - providerEntry.source === "file" - ? providerEntry.mode === "singleValue" - ? "value" - : defaultFilePointer - : `${params.provider}/apiKey`; - const idRaw = await params.prompter.text({ - message: idPrompt, - initialValue: idDefault, - placeholder: providerEntry.source === "file" ? "/providers/openai/apiKey" : "openai/api-key", - validate: (value) => { - const candidate = value.trim(); - if (!candidate) { - return "Secret id cannot be empty."; - } - if ( - providerEntry.source === "file" && - providerEntry.mode !== "singleValue" && - !isValidFileSecretRefId(candidate) - ) { - return 'Use an absolute JSON pointer like "/providers/openai/apiKey".'; - } - if ( - providerEntry.source === "file" && - providerEntry.mode === "singleValue" && - candidate !== "value" - ) { - return 'singleValue mode expects id "value".'; - } - if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) { - return formatExecSecretRefIdValidationMessage(); - } - return undefined; - }, - }); - const id = String(idRaw ?? "").trim() || idDefault; - const ref: SecretRef = { - source: providerEntry.source, - provider: selectedProvider, - id, - }; - try { - const resolvedValue = await resolveSecretRefString(ref, { - config: params.config, - env: process.env, - }); - await params.prompter.note( - params.copy?.providerValidatedMessage?.(selectedProvider, id, providerEntry.source) ?? - `Validated ${providerEntry.source} reference ${selectedProvider}:${id}. OpenClaw will store a reference, not the key value.`, - "Reference validated", - ); - return { ref, resolvedValue }; - } catch (error) { - await params.prompter.note( - [ - `Could not validate provider reference ${selectedProvider}:${id}.`, - formatErrorMessage(error), - "Check your provider configuration and try again.", - ].join("\n"), - "Reference check failed", - ); - } - } -} +export type { + SecretInputModePromptCopy, + SecretRefSetupPromptCopy, +} from "../plugins/provider-auth-input.js"; +export { + ensureApiKeyFromEnvOrPrompt, + ensureApiKeyFromOptionEnvOrPrompt, + maybeApplyApiKeyFromOption, + normalizeSecretInputModeInput, + normalizeTokenProviderInput, + promptSecretRefForSetup, + resolveSecretInputModeForEnvSelection, +} from "../plugins/provider-auth-input.js"; export function createAuthChoiceAgentModelNoter( params: ApplyAuthChoiceParams, @@ -358,180 +97,3 @@ export function createAuthChoiceDefaultModelApplierForMutableState( }), ); } - -export function normalizeTokenProviderInput( - tokenProvider: string | null | undefined, -): string | undefined { - const normalized = String(tokenProvider ?? "") - .trim() - .toLowerCase(); - return normalized || undefined; -} - -export function normalizeSecretInputModeInput( - secretInputMode: string | null | undefined, -): SecretInputMode | undefined { - const normalized = String(secretInputMode ?? "") - .trim() - .toLowerCase(); - if (normalized === "plaintext" || normalized === "ref") { - return normalized; - } - return undefined; -} - -export async function resolveSecretInputModeForEnvSelection(params: { - prompter: WizardPrompter; - explicitMode?: SecretInputMode; - copy?: SecretInputModePromptCopy; -}): Promise { - if (params.explicitMode) { - return params.explicitMode; - } - // Some tests pass partial prompt harnesses without a select implementation. - // Preserve backward-compatible behavior by defaulting to plaintext in that case. - if (typeof params.prompter.select !== "function") { - return "plaintext"; - } - const selected = await params.prompter.select({ - message: params.copy?.modeMessage ?? "How do you want to provide this API key?", - initialValue: "plaintext", - options: [ - { - value: "plaintext", - label: params.copy?.plaintextLabel ?? "Paste API key now", - hint: params.copy?.plaintextHint ?? "Stores the key directly in OpenClaw config", - }, - { - value: "ref", - label: params.copy?.refLabel ?? "Use external secret provider", - hint: - params.copy?.refHint ?? - "Stores a reference to env or configured external secret providers", - }, - ], - }); - return selected === "ref" ? "ref" : "plaintext"; -} - -export async function maybeApplyApiKeyFromOption(params: { - token: string | undefined; - tokenProvider: string | undefined; - secretInputMode?: SecretInputMode; - expectedProviders: string[]; - normalize: (value: string) => string; - setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; -}): Promise { - const tokenProvider = normalizeTokenProviderInput(params.tokenProvider); - const expectedProviders = params.expectedProviders - .map((provider) => normalizeTokenProviderInput(provider)) - .filter((provider): provider is string => Boolean(provider)); - if (!params.token || !tokenProvider || !expectedProviders.includes(tokenProvider)) { - return undefined; - } - const apiKey = params.normalize(params.token); - await params.setCredential(apiKey, params.secretInputMode); - return apiKey; -} - -export async function ensureApiKeyFromOptionEnvOrPrompt(params: { - token: string | undefined; - tokenProvider: string | undefined; - secretInputMode?: SecretInputMode; - config: OpenClawConfig; - expectedProviders: string[]; - provider: string; - envLabel: string; - promptMessage: string; - normalize: (value: string) => string; - validate: (value: string) => string | undefined; - prompter: WizardPrompter; - setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; - noteMessage?: string; - noteTitle?: string; -}): Promise { - const optionApiKey = await maybeApplyApiKeyFromOption({ - token: params.token, - tokenProvider: params.tokenProvider, - secretInputMode: params.secretInputMode, - expectedProviders: params.expectedProviders, - normalize: params.normalize, - setCredential: params.setCredential, - }); - if (optionApiKey) { - return optionApiKey; - } - - if (params.noteMessage) { - await params.prompter.note(params.noteMessage, params.noteTitle); - } - - return await ensureApiKeyFromEnvOrPrompt({ - config: params.config, - provider: params.provider, - envLabel: params.envLabel, - promptMessage: params.promptMessage, - normalize: params.normalize, - validate: params.validate, - prompter: params.prompter, - secretInputMode: params.secretInputMode, - setCredential: params.setCredential, - }); -} - -export async function ensureApiKeyFromEnvOrPrompt(params: { - config: OpenClawConfig; - provider: string; - envLabel: string; - promptMessage: string; - normalize: (value: string) => string; - validate: (value: string) => string | undefined; - prompter: WizardPrompter; - secretInputMode?: SecretInputMode; - setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; -}): Promise { - const selectedMode = await resolveSecretInputModeForEnvSelection({ - prompter: params.prompter, - explicitMode: params.secretInputMode, - }); - const envKey = resolveEnvApiKey(params.provider); - - if (selectedMode === "ref") { - if (typeof params.prompter.select !== "function") { - const fallback = resolveRefFallbackInput({ - config: params.config, - provider: params.provider, - preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined, - }); - await params.setCredential(fallback.ref, selectedMode); - return fallback.resolvedValue; - } - const resolved = await promptSecretRefForSetup({ - provider: params.provider, - config: params.config, - prompter: params.prompter, - preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined, - }); - await params.setCredential(resolved.ref, selectedMode); - return resolved.resolvedValue; - } - - if (envKey && selectedMode === "plaintext") { - const useExisting = await params.prompter.confirm({ - message: `Use existing ${params.envLabel} (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await params.setCredential(envKey.apiKey, selectedMode); - return envKey.apiKey; - } - } - - const key = await params.prompter.text({ - message: params.promptMessage, - validate: params.validate, - }); - const apiKey = params.normalize(String(key ?? "")); - await params.setCredential(apiKey, selectedMode); - return apiKey; -} diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index afdad97ecec..ce459020039 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -8,7 +8,7 @@ import { upsertAuthProfile } from "../agents/auth-profiles.js"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; -import type { ProviderAuthMethod } from "../plugins/types.js"; +import type { ProviderAuthMethod, ProviderAuthOptionBag } from "../plugins/types.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { isRemoteEnvironment } from "./oauth-env.js"; import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; @@ -97,7 +97,7 @@ export async function runProviderPluginAuthMethod(params: { workspaceDir, prompter: params.prompter, runtime: params.runtime, - opts: params.opts, + opts: params.opts as ProviderAuthOptionBag | undefined, secretInputMode: params.secretInputMode, allowSecretRefPrompt: params.allowSecretRefPrompt, isRemote, @@ -173,7 +173,7 @@ export async function applyAuthChoiceLoadedPluginProvider( workspaceDir, secretInputMode: params.opts?.secretInputMode, allowSecretRefPrompt: false, - opts: params.opts, + opts: params.opts as ProviderAuthOptionBag | undefined, }); let nextConfig = applied.config; @@ -260,7 +260,7 @@ export async function applyAuthChoicePluginProvider( workspaceDir, secretInputMode: params.opts?.secretInputMode, allowSecretRefPrompt: false, - opts: params.opts, + opts: params.opts as ProviderAuthOptionBag | undefined, }); nextConfig = applied.config; diff --git a/src/commands/auth-token.ts b/src/commands/auth-token.ts index d003c2aa1b7..b371599b222 100644 --- a/src/commands/auth-token.ts +++ b/src/commands/auth-token.ts @@ -1,38 +1,8 @@ -import { normalizeProviderId } from "../agents/model-selection.js"; - -export const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-"; -export const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80; -export const DEFAULT_TOKEN_PROFILE_NAME = "default"; - -export function normalizeTokenProfileName(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return DEFAULT_TOKEN_PROFILE_NAME; - } - const slug = trimmed - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-+|-+$/g, ""); - return slug || DEFAULT_TOKEN_PROFILE_NAME; -} - -export function buildTokenProfileId(params: { provider: string; name: string }): string { - const provider = normalizeProviderId(params.provider); - const name = normalizeTokenProfileName(params.name); - return `${provider}:${name}`; -} - -export function validateAnthropicSetupToken(raw: string): string | undefined { - const trimmed = raw.trim(); - if (!trimmed) { - return "Required"; - } - if (!trimmed.startsWith(ANTHROPIC_SETUP_TOKEN_PREFIX)) { - return `Expected token starting with ${ANTHROPIC_SETUP_TOKEN_PREFIX}`; - } - if (trimmed.length < ANTHROPIC_SETUP_TOKEN_MIN_LENGTH) { - return "Token looks too short; paste the full setup-token"; - } - return undefined; -} +export { + ANTHROPIC_SETUP_TOKEN_MIN_LENGTH, + ANTHROPIC_SETUP_TOKEN_PREFIX, + buildTokenProfileId, + DEFAULT_TOKEN_PROFILE_NAME, + normalizeTokenProfileName, + validateAnthropicSetupToken, +} from "../plugins/provider-auth-token.js"; diff --git a/src/commands/google-gemini-model-default.ts b/src/commands/google-gemini-model-default.ts index 491fdd3c6d9..25b92d6459f 100644 --- a/src/commands/google-gemini-model-default.ts +++ b/src/commands/google-gemini-model-default.ts @@ -1,11 +1,4 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultPrimaryModel } from "./model-default.js"; - -export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview"; - -export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): { - next: OpenClawConfig; - changed: boolean; -} { - return applyAgentDefaultPrimaryModel({ cfg, model: GOOGLE_GEMINI_DEFAULT_MODEL }); -} +export { + applyGoogleGeminiModelDefault, + GOOGLE_GEMINI_DEFAULT_MODEL, +} from "../plugins/provider-model-defaults.js"; diff --git a/src/commands/model-allowlist.ts b/src/commands/model-allowlist.ts index bc6dfc5308d..37f664aef36 100644 --- a/src/commands/model-allowlist.ts +++ b/src/commands/model-allowlist.ts @@ -1,41 +1 @@ -import { DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { resolveAllowlistModelKey } from "../agents/model-selection.js"; -import type { OpenClawConfig } from "../config/config.js"; - -export function ensureModelAllowlistEntry(params: { - cfg: OpenClawConfig; - modelRef: string; - defaultProvider?: string; -}): OpenClawConfig { - const rawModelRef = params.modelRef.trim(); - if (!rawModelRef) { - return params.cfg; - } - - const models = { ...params.cfg.agents?.defaults?.models }; - const keySet = new Set([rawModelRef]); - const canonicalKey = resolveAllowlistModelKey( - rawModelRef, - params.defaultProvider ?? DEFAULT_PROVIDER, - ); - if (canonicalKey) { - keySet.add(canonicalKey); - } - - for (const key of keySet) { - models[key] = { - ...models[key], - }; - } - - return { - ...params.cfg, - agents: { - ...params.cfg.agents, - defaults: { - ...params.cfg.agents?.defaults, - models, - }, - }, - }; -} +export { ensureModelAllowlistEntry } from "../plugins/provider-model-allowlist.js"; diff --git a/src/commands/model-default.ts b/src/commands/model-default.ts index ce121973da3..d70e5208f3b 100644 --- a/src/commands/model-default.ts +++ b/src/commands/model-default.ts @@ -1,45 +1,4 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { AgentModelListConfig } from "../config/types.js"; - -export function resolvePrimaryModel(model?: AgentModelListConfig | string): string | undefined { - if (typeof model === "string") { - return model; - } - if (model && typeof model === "object" && typeof model.primary === "string") { - return model.primary; - } - return undefined; -} - -export function applyAgentDefaultPrimaryModel(params: { - cfg: OpenClawConfig; - model: string; - legacyModels?: Set; -}): { next: OpenClawConfig; changed: boolean } { - const current = resolvePrimaryModel(params.cfg.agents?.defaults?.model)?.trim(); - const normalizedCurrent = current && params.legacyModels?.has(current) ? params.model : current; - if (normalizedCurrent === params.model) { - return { next: params.cfg, changed: false }; - } - - return { - next: { - ...params.cfg, - agents: { - ...params.cfg.agents, - defaults: { - ...params.cfg.agents?.defaults, - model: - params.cfg.agents?.defaults?.model && - typeof params.cfg.agents.defaults.model === "object" - ? { - ...params.cfg.agents.defaults.model, - primary: params.model, - } - : { primary: params.model }, - }, - }, - }, - changed: true, - }; -} +export { + applyAgentDefaultPrimaryModel, + resolvePrimaryModel, +} from "../plugins/provider-model-primary.js"; diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index 483997511cb..c0b67ea7d7c 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -11,10 +11,13 @@ import { } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import { applyPrimaryModel } from "../plugins/provider-model-primary.js"; import type { ProviderPlugin } from "../plugins/types.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; import { formatTokenK } from "./models/shared.js"; +export { applyPrimaryModel } from "../plugins/provider-model-primary.js"; + const KEEP_VALUE = "__keep__"; const MANUAL_VALUE = "__manual__"; const PROVIDER_FILTER_THRESHOLD = 30; @@ -516,33 +519,6 @@ export async function promptModelAllowlist(params: { return { models: [] }; } -export function applyPrimaryModel(cfg: OpenClawConfig, model: string): OpenClawConfig { - const defaults = cfg.agents?.defaults; - const existingModel = defaults?.model; - const existingModels = defaults?.models; - const fallbacks = - typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel - ? (existingModel as { fallbacks?: string[] }).fallbacks - : undefined; - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...defaults, - model: { - ...(fallbacks ? { fallbacks } : undefined), - primary: model, - }, - models: { - ...existingModels, - [model]: existingModels?.[model] ?? {}, - }, - }, - }, - }; -} - export function applyModelAllowlist(cfg: OpenClawConfig, models: string[]): OpenClawConfig { const defaults = cfg.agents?.defaults; const normalized = normalizeModelKeys(models); diff --git a/src/commands/oauth-flow.ts b/src/commands/oauth-flow.ts index 1b0eba3b4f8..48e89b25720 100644 --- a/src/commands/oauth-flow.ts +++ b/src/commands/oauth-flow.ts @@ -1,53 +1 @@ -import type { RuntimeEnv } from "../runtime.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; - -type OAuthPrompt = { message: string; placeholder?: string }; - -const validateRequiredInput = (value: string) => (value.trim().length > 0 ? undefined : "Required"); - -export function createVpsAwareOAuthHandlers(params: { - isRemote: boolean; - prompter: WizardPrompter; - runtime: RuntimeEnv; - spin: ReturnType; - openUrl: (url: string) => Promise; - localBrowserMessage: string; - manualPromptMessage?: string; -}): { - onAuth: (event: { url: string }) => Promise; - onPrompt: (prompt: OAuthPrompt) => Promise; -} { - const manualPromptMessage = params.manualPromptMessage ?? "Paste the redirect URL"; - let manualCodePromise: Promise | undefined; - - return { - onAuth: async ({ url }) => { - if (params.isRemote) { - params.spin.stop("OAuth URL ready"); - params.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); - manualCodePromise = params.prompter - .text({ - message: manualPromptMessage, - validate: validateRequiredInput, - }) - .then((value) => String(value)); - return; - } - - params.spin.update(params.localBrowserMessage); - await params.openUrl(url); - params.runtime.log(`Open: ${url}`); - }, - onPrompt: async (prompt) => { - if (manualCodePromise) { - return manualCodePromise; - } - const code = await params.prompter.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: validateRequiredInput, - }); - return String(code); - }, - }; -} +export * from "../plugins/provider-oauth-flow.js"; diff --git a/src/commands/oauth-tls-preflight.ts b/src/commands/oauth-tls-preflight.ts index bf9e69b0519..6852c58ad5c 100644 --- a/src/commands/oauth-tls-preflight.ts +++ b/src/commands/oauth-tls-preflight.ts @@ -1,164 +1 @@ -import path from "node:path"; -import { formatCliCommand } from "../cli/command-format.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { note } from "../terminal/note.js"; - -const TLS_CERT_ERROR_CODES = new Set([ - "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", - "UNABLE_TO_VERIFY_LEAF_SIGNATURE", - "CERT_HAS_EXPIRED", - "DEPTH_ZERO_SELF_SIGNED_CERT", - "SELF_SIGNED_CERT_IN_CHAIN", - "ERR_TLS_CERT_ALTNAME_INVALID", -]); - -const TLS_CERT_ERROR_PATTERNS = [ - /unable to get local issuer certificate/i, - /unable to verify the first certificate/i, - /self[- ]signed certificate/i, - /certificate has expired/i, -]; - -const OPENAI_AUTH_PROBE_URL = - "https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email"; - -type PreflightFailureKind = "tls-cert" | "network"; - -export type OpenAIOAuthTlsPreflightResult = - | { ok: true } - | { - ok: false; - kind: PreflightFailureKind; - code?: string; - message: string; - }; - -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" ? (value as Record) : null; -} - -function extractFailure(error: unknown): { - code?: string; - message: string; - kind: PreflightFailureKind; -} { - const root = asRecord(error); - const rootCause = asRecord(root?.cause); - const code = typeof rootCause?.code === "string" ? rootCause.code : undefined; - const message = - typeof rootCause?.message === "string" - ? rootCause.message - : typeof root?.message === "string" - ? root.message - : String(error); - const isTlsCertError = - (code ? TLS_CERT_ERROR_CODES.has(code) : false) || - TLS_CERT_ERROR_PATTERNS.some((pattern) => pattern.test(message)); - return { - code, - message, - kind: isTlsCertError ? "tls-cert" : "network", - }; -} - -function resolveHomebrewPrefixFromExecPath(execPath: string): string | null { - const marker = `${path.sep}Cellar${path.sep}`; - const idx = execPath.indexOf(marker); - if (idx > 0) { - return execPath.slice(0, idx); - } - const envPrefix = process.env.HOMEBREW_PREFIX?.trim(); - return envPrefix ? envPrefix : null; -} - -function resolveCertBundlePath(): string | null { - const prefix = resolveHomebrewPrefixFromExecPath(process.execPath); - if (!prefix) { - return null; - } - return path.join(prefix, "etc", "openssl@3", "cert.pem"); -} - -function hasOpenAICodexOAuthProfile(cfg: OpenClawConfig): boolean { - const profiles = cfg.auth?.profiles; - if (!profiles) { - return false; - } - return Object.values(profiles).some( - (profile) => profile.provider === "openai-codex" && profile.mode === "oauth", - ); -} - -function shouldRunOpenAIOAuthTlsPrerequisites(params: { - cfg: OpenClawConfig; - deep?: boolean; -}): boolean { - if (params.deep === true) { - return true; - } - return hasOpenAICodexOAuthProfile(params.cfg); -} - -export async function runOpenAIOAuthTlsPreflight(options?: { - timeoutMs?: number; - fetchImpl?: typeof fetch; -}): Promise { - const timeoutMs = options?.timeoutMs ?? 5000; - const fetchImpl = options?.fetchImpl ?? fetch; - try { - await fetchImpl(OPENAI_AUTH_PROBE_URL, { - method: "GET", - redirect: "manual", - signal: AbortSignal.timeout(timeoutMs), - }); - return { ok: true }; - } catch (error) { - const failure = extractFailure(error); - return { - ok: false, - kind: failure.kind, - code: failure.code, - message: failure.message, - }; - } -} - -export function formatOpenAIOAuthTlsPreflightFix( - result: Exclude, -): string { - if (result.kind !== "tls-cert") { - return [ - "OpenAI OAuth prerequisites check failed due to a network error before the browser flow.", - `Cause: ${result.message}`, - "Verify DNS/firewall/proxy access to auth.openai.com and retry.", - ].join("\n"); - } - const certBundlePath = resolveCertBundlePath(); - const lines = [ - "OpenAI OAuth prerequisites check failed: Node/OpenSSL cannot validate TLS certificates.", - `Cause: ${result.code ? `${result.code} (${result.message})` : result.message}`, - "", - "Fix (Homebrew Node/OpenSSL):", - `- ${formatCliCommand("brew postinstall ca-certificates")}`, - `- ${formatCliCommand("brew postinstall openssl@3")}`, - ]; - if (certBundlePath) { - lines.push(`- Verify cert bundle exists: ${certBundlePath}`); - } - lines.push("- Retry the OAuth login flow."); - return lines.join("\n"); -} - -export async function noteOpenAIOAuthTlsPrerequisites(params: { - cfg: OpenClawConfig; - deep?: boolean; -}): Promise { - if (!shouldRunOpenAIOAuthTlsPrerequisites(params)) { - return; - } - const result = await runOpenAIOAuthTlsPreflight({ timeoutMs: 4000 }); - if (result.ok || result.kind !== "tls-cert") { - return; - } - note(formatOpenAIOAuthTlsPreflightFix(result), "OAuth TLS prerequisites"); -} +export * from "../plugins/provider-openai-codex-oauth-tls.js"; 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 3f11a7367a9..54f25857441 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 @@ -8,6 +8,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../../../agents/workspace.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { enablePluginInConfig } from "../../../plugins/enable.js"; import type { + ProviderAuthOptionBag, ProviderNonInteractiveApiKeyCredentialParams, ProviderResolveNonInteractiveApiKeyParams, } from "../../../plugins/types.js"; @@ -130,7 +131,7 @@ export async function applyNonInteractivePluginProviderChoice(params: { authChoice: params.authChoice, config: enableResult.config, baseConfig: params.baseConfig, - opts: params.opts, + opts: params.opts as ProviderAuthOptionBag, runtime: params.runtime, agentDir, workspaceDir, diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 9d738298e52..832fae75448 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -1,4 +1,5 @@ import type { ChannelId } from "../channels/plugins/types.js"; +import type { SecretInputMode } from "../plugins/provider-auth-types.js"; import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; export type OnboardMode = "local" | "remote"; @@ -90,7 +91,7 @@ export type NodeManagerChoice = "npm" | "pnpm" | "bun"; export type ChannelChoice = ChannelId; // Legacy alias (pre-rename). export type ProviderChoice = ChannelChoice; -export type SecretInputMode = "plaintext" | "ref"; // pragma: allowlist secret +export type { SecretInputMode } from "../plugins/provider-auth-types.js"; export type OnboardOptions = { mode?: OnboardMode; diff --git a/src/commands/openai-codex-oauth.ts b/src/commands/openai-codex-oauth.ts index a868217750b..0c5f098c41f 100644 --- a/src/commands/openai-codex-oauth.ts +++ b/src/commands/openai-codex-oauth.ts @@ -1,65 +1 @@ -import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai/oauth"; -import type { RuntimeEnv } from "../runtime.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; -import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; -import { - formatOpenAIOAuthTlsPreflightFix, - runOpenAIOAuthTlsPreflight, -} from "./oauth-tls-preflight.js"; - -export async function loginOpenAICodexOAuth(params: { - prompter: WizardPrompter; - runtime: RuntimeEnv; - isRemote: boolean; - openUrl: (url: string) => Promise; - localBrowserMessage?: string; -}): Promise { - const { prompter, runtime, isRemote, openUrl, localBrowserMessage } = params; - const preflight = await runOpenAIOAuthTlsPreflight(); - if (!preflight.ok && preflight.kind === "tls-cert") { - const hint = formatOpenAIOAuthTlsPreflightFix(preflight); - runtime.error(hint); - await prompter.note(hint, "OAuth prerequisites"); - throw new Error(preflight.message); - } - - await prompter.note( - isRemote - ? [ - "You are running in a remote/VPS environment.", - "A URL will be shown for you to open in your LOCAL browser.", - "After signing in, paste the redirect URL back here.", - ].join("\n") - : [ - "Browser will open for OpenAI authentication.", - "If the callback doesn't auto-complete, paste the redirect URL.", - "OpenAI OAuth uses localhost:1455 for the callback.", - ].join("\n"), - "OpenAI Codex OAuth", - ); - - const spin = prompter.progress("Starting OAuth flow…"); - try { - const { onAuth: baseOnAuth, onPrompt } = createVpsAwareOAuthHandlers({ - isRemote, - prompter, - runtime, - spin, - openUrl, - localBrowserMessage: localBrowserMessage ?? "Complete sign-in in browser…", - }); - - const creds = await loginOpenAICodex({ - onAuth: baseOnAuth, - onPrompt, - onProgress: (msg: string) => spin.update(msg), - }); - spin.stop("OpenAI OAuth complete"); - return creds ?? null; - } catch (err) { - spin.stop("OpenAI OAuth failed"); - runtime.error(String(err)); - await prompter.note("Trouble with OAuth? See https://docs.openclaw.ai/start/faq", "OAuth help"); - throw err; - } -} +export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js"; diff --git a/src/commands/openai-model-default.ts b/src/commands/openai-model-default.ts index 191756e0fa0..81316e753ed 100644 --- a/src/commands/openai-model-default.ts +++ b/src/commands/openai-model-default.ts @@ -1,47 +1,5 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { ensureModelAllowlistEntry } from "./model-allowlist.js"; - -export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.1-codex"; - -export function applyOpenAIProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = ensureModelAllowlistEntry({ - cfg, - modelRef: OPENAI_DEFAULT_MODEL, - }); - const models = { ...next.agents?.defaults?.models }; - models[OPENAI_DEFAULT_MODEL] = { - ...models[OPENAI_DEFAULT_MODEL], - alias: models[OPENAI_DEFAULT_MODEL]?.alias ?? "GPT", - }; - - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - models, - }, - }, - }; -} - -export function applyOpenAIConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyOpenAIProviderConfig(cfg); - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: - next.agents?.defaults?.model && typeof next.agents.defaults.model === "object" - ? { - ...next.agents.defaults.model, - primary: OPENAI_DEFAULT_MODEL, - } - : { primary: OPENAI_DEFAULT_MODEL }, - }, - }, - }; -} +export { + applyOpenAIConfig, + applyOpenAIProviderConfig, + OPENAI_DEFAULT_MODEL, +} from "../plugins/provider-model-defaults.js"; diff --git a/src/commands/opencode-go-model-default.ts b/src/commands/opencode-go-model-default.ts index c959f23ff2e..c87816456c3 100644 --- a/src/commands/opencode-go-model-default.ts +++ b/src/commands/opencode-go-model-default.ts @@ -1,11 +1,4 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultPrimaryModel } from "./model-default.js"; - -export const OPENCODE_GO_DEFAULT_MODEL_REF = "opencode-go/kimi-k2.5"; - -export function applyOpencodeGoModelDefault(cfg: OpenClawConfig): { - next: OpenClawConfig; - changed: boolean; -} { - return applyAgentDefaultPrimaryModel({ cfg, model: OPENCODE_GO_DEFAULT_MODEL_REF }); -} +export { + applyOpencodeGoModelDefault, + OPENCODE_GO_DEFAULT_MODEL_REF, +} from "../plugins/provider-model-defaults.js"; diff --git a/src/commands/opencode-zen-model-default.ts b/src/commands/opencode-zen-model-default.ts index 9efb9c17ade..0d874241076 100644 --- a/src/commands/opencode-zen-model-default.ts +++ b/src/commands/opencode-zen-model-default.ts @@ -1,19 +1,4 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultPrimaryModel } from "./model-default.js"; - -export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode/claude-opus-4-6"; -const LEGACY_OPENCODE_ZEN_DEFAULT_MODELS = new Set([ - "opencode/claude-opus-4-5", - "opencode-zen/claude-opus-4-5", -]); - -export function applyOpencodeZenModelDefault(cfg: OpenClawConfig): { - next: OpenClawConfig; - changed: boolean; -} { - return applyAgentDefaultPrimaryModel({ - cfg, - model: OPENCODE_ZEN_DEFAULT_MODEL, - legacyModels: LEGACY_OPENCODE_ZEN_DEFAULT_MODELS, - }); -} +export { + applyOpencodeZenModelDefault, + OPENCODE_ZEN_DEFAULT_MODEL, +} from "../plugins/provider-model-defaults.js"; diff --git a/src/commands/self-hosted-provider-setup.ts b/src/commands/self-hosted-provider-setup.ts index e7851fdf550..2b1e0a3027b 100644 --- a/src/commands/self-hosted-provider-setup.ts +++ b/src/commands/self-hosted-provider-setup.ts @@ -13,6 +13,7 @@ import type { ProviderAuthMethodNonInteractiveContext, ProviderNonInteractiveApiKeyResult, } from "../plugins/types.js"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import type { WizardPrompter } from "../wizard/prompts.js"; export { @@ -240,11 +241,10 @@ export async function configureOpenAICompatibleSelfHostedProviderNonInteractive( contextWindow?: number; maxTokens?: number; }): Promise { - const baseUrl = (params.ctx.opts.customBaseUrl?.trim() || params.defaultBaseUrl).replace( - /\/+$/, - "", - ); - const modelId = params.ctx.opts.customModelId?.trim(); + const baseUrl = ( + normalizeOptionalSecretInput(params.ctx.opts.customBaseUrl) ?? params.defaultBaseUrl + ).replace(/\/+$/, ""); + const modelId = normalizeOptionalSecretInput(params.ctx.opts.customModelId); if (!modelId) { params.ctx.runtime.error( buildMissingNonInteractiveModelIdMessage({ @@ -259,7 +259,7 @@ export async function configureOpenAICompatibleSelfHostedProviderNonInteractive( const resolved = await params.ctx.resolveApiKey({ provider: params.providerId, - flagValue: params.ctx.opts.customApiKey, + flagValue: normalizeOptionalSecretInput(params.ctx.opts.customApiKey), flagName: "--custom-api-key", envVar: params.defaultApiKeyEnvVar, envVarName: params.defaultApiKeyEnvVar, diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index bb0c307c294..baecefe62e9 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -21,17 +21,20 @@ export { formatApiKeyPreview, normalizeApiKeyInput, validateApiKeyInput, -} from "../commands/auth-choice.api-key.js"; +} from "../plugins/provider-auth-input.js"; export { ensureApiKeyFromOptionEnvOrPrompt, normalizeSecretInputModeInput, promptSecretRefForSetup, resolveSecretInputModeForEnvSelection, -} from "../commands/auth-choice.apply-helpers.js"; -export { buildTokenProfileId, validateAnthropicSetupToken } from "../commands/auth-token.js"; +} from "../plugins/provider-auth-input.js"; +export { + buildTokenProfileId, + validateAnthropicSetupToken, +} from "../plugins/provider-auth-token.js"; export { applyAuthProfileConfig, buildApiKeyCredential } from "../plugins/provider-auth-helpers.js"; export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; -export { loginOpenAICodexOAuth } from "../commands/openai-codex-oauth.js"; +export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js"; export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; export { coerceSecretRef } from "../config/types.secrets.js"; export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index f0a85fe1ed1..5694a540075 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -14,10 +14,10 @@ export { normalizeProviderId } from "../agents/provider-id.js"; export { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL, -} from "../commands/google-gemini-model-default.js"; -export { applyOpenAIConfig, OPENAI_DEFAULT_MODEL } from "../commands/openai-model-default.js"; -export { OPENCODE_GO_DEFAULT_MODEL_REF } from "../commands/opencode-go-model-default.js"; -export { OPENCODE_ZEN_DEFAULT_MODEL } from "../commands/opencode-zen-model-default.js"; +} from "../plugins/provider-model-defaults.js"; +export { applyOpenAIConfig, OPENAI_DEFAULT_MODEL } from "../plugins/provider-model-defaults.js"; +export { OPENCODE_GO_DEFAULT_MODEL_REF } from "../plugins/provider-model-defaults.js"; +export { OPENCODE_ZEN_DEFAULT_MODEL } from "../plugins/provider-model-defaults.js"; export { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; export * from "../plugins/provider-model-definitions.js"; diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts index 89b219bedbc..35b9287bcc8 100644 --- a/src/plugin-sdk/provider-onboard.ts +++ b/src/plugin-sdk/provider-onboard.ts @@ -13,4 +13,4 @@ export { applyProviderConfigWithDefaultModels, applyProviderConfigWithModelCatalog, } from "../plugins/provider-onboarding-config.js"; -export { ensureModelAllowlistEntry } from "../commands/model-allowlist.js"; +export { ensureModelAllowlistEntry } from "../plugins/provider-model-allowlist.js"; diff --git a/src/plugins/provider-api-key-auth.runtime.ts b/src/plugins/provider-api-key-auth.runtime.ts index dade8720478..ad37b986b91 100644 --- a/src/plugins/provider-api-key-auth.runtime.ts +++ b/src/plugins/provider-api-key-auth.runtime.ts @@ -1,7 +1,10 @@ -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 { applyAuthProfileConfig, buildApiKeyCredential } from "./provider-auth-helpers.js"; +import { + ensureApiKeyFromOptionEnvOrPrompt, + normalizeApiKeyInput, + validateApiKeyInput, +} from "./provider-auth-input.js"; +import { applyPrimaryModel } from "./provider-model-primary.js"; export { applyAuthProfileConfig, diff --git a/src/plugins/provider-auth-helpers.ts b/src/plugins/provider-auth-helpers.ts index 72075dffc00..bf397044eae 100644 --- a/src/plugins/provider-auth-helpers.ts +++ b/src/plugins/provider-auth-helpers.ts @@ -4,7 +4,6 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { upsertAuthProfile } from "../agents/auth-profiles.js"; import { normalizeProviderIdForAuth } from "../agents/provider-id.js"; -import type { SecretInputMode } from "../commands/onboard-types.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { @@ -15,6 +14,7 @@ import { } from "../config/types.secrets.js"; import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; +import type { SecretInputMode } from "./provider-auth-types.js"; const ENV_REF_PATTERN = /^\$\{([A-Z][A-Z0-9_]*)\}$/; diff --git a/src/plugins/provider-auth-input.ts b/src/plugins/provider-auth-input.ts new file mode 100644 index 00000000000..02abf92592d --- /dev/null +++ b/src/plugins/provider-auth-input.ts @@ -0,0 +1,496 @@ +import { resolveEnvApiKey } from "../agents/model-auth.js"; +import type { OpenClawConfig } from "../config/types.js"; +import { + isValidEnvSecretRefId, + type SecretInput, + type SecretRef, +} from "../config/types.secrets.js"; +import { encodeJsonPointerToken } from "../secrets/json-pointer.js"; +import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; +import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, + isValidFileSecretRefId, + resolveDefaultSecretProviderAlias, +} from "../secrets/ref-contract.js"; +import { resolveSecretRefString } from "../secrets/resolve.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import type { SecretInputMode } from "./provider-auth-types.js"; + +const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 }; +const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/; + +type SecretRefChoice = "env" | "provider"; // pragma: allowlist secret + +export type SecretInputModePromptCopy = { + modeMessage?: string; + plaintextLabel?: string; + plaintextHint?: string; + refLabel?: string; + refHint?: string; +}; + +export type SecretRefSetupPromptCopy = { + sourceMessage?: string; + envVarMessage?: string; + envVarPlaceholder?: string; + envVarFormatError?: string; + envVarMissingError?: (envVar: string) => string; + noProvidersMessage?: string; + envValidatedMessage?: (envVar: string) => string; + providerValidatedMessage?: (provider: string, id: string, source: "file" | "exec") => string; +}; + +export function normalizeApiKeyInput(raw: string): string { + const trimmed = String(raw ?? "").trim(); + if (!trimmed) { + return ""; + } + + const assignmentMatch = trimmed.match(/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/); + const valuePart = assignmentMatch ? assignmentMatch[1].trim() : trimmed; + + const unquoted = + valuePart.length >= 2 && + ((valuePart.startsWith('"') && valuePart.endsWith('"')) || + (valuePart.startsWith("'") && valuePart.endsWith("'")) || + (valuePart.startsWith("`") && valuePart.endsWith("`"))) + ? valuePart.slice(1, -1) + : valuePart; + + const withoutSemicolon = unquoted.endsWith(";") ? unquoted.slice(0, -1) : unquoted; + + return withoutSemicolon.trim(); +} + +export const validateApiKeyInput = (value: string) => + normalizeApiKeyInput(value).length > 0 ? undefined : "Required"; + +export function formatApiKeyPreview( + raw: string, + opts: { head?: number; tail?: number } = {}, +): string { + const trimmed = raw.trim(); + if (!trimmed) { + return "…"; + } + const head = opts.head ?? DEFAULT_KEY_PREVIEW.head; + const tail = opts.tail ?? DEFAULT_KEY_PREVIEW.tail; + if (trimmed.length <= head + tail) { + const shortHead = Math.min(2, trimmed.length); + const shortTail = Math.min(2, trimmed.length - shortHead); + if (shortTail <= 0) { + return `${trimmed.slice(0, shortHead)}…`; + } + return `${trimmed.slice(0, shortHead)}…${trimmed.slice(-shortTail)}`; + } + return `${trimmed.slice(0, head)}…${trimmed.slice(-tail)}`; +} + +function formatErrorMessage(error: unknown): string { + if (error instanceof Error && typeof error.message === "string" && error.message.trim()) { + return error.message; + } + return String(error); +} + +function extractEnvVarFromSourceLabel(source: string): string | undefined { + const match = ENV_SOURCE_LABEL_RE.exec(source.trim()); + return match?.[1]; +} + +function resolveDefaultProviderEnvVar(provider: string): string | undefined { + const envVars = PROVIDER_ENV_VARS[provider]; + return envVars?.find((candidate) => candidate.trim().length > 0); +} + +function resolveDefaultFilePointerId(provider: string): string { + return `/providers/${encodeJsonPointerToken(provider)}/apiKey`; +} + +function resolveRefFallbackInput(params: { + config: OpenClawConfig; + provider: string; + preferredEnvVar?: string; +}): { ref: SecretRef; resolvedValue: string } { + const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider); + if (!fallbackEnvVar) { + throw new Error( + `No default environment variable mapping found for provider "${params.provider}". Set a provider-specific env var, or re-run setup in an interactive terminal to configure a ref.`, + ); + } + const value = process.env[fallbackEnvVar]?.trim(); + if (!value) { + throw new Error( + `Environment variable "${fallbackEnvVar}" is required for --secret-input-mode ref in non-interactive setup.`, + ); + } + return { + ref: { + source: "env", + provider: resolveDefaultSecretProviderAlias(params.config, "env", { + preferFirstProviderForSource: true, + }), + id: fallbackEnvVar, + }, + resolvedValue: value, + }; +} + +export async function promptSecretRefForSetup(params: { + provider: string; + config: OpenClawConfig; + prompter: WizardPrompter; + preferredEnvVar?: string; + copy?: SecretRefSetupPromptCopy; +}): Promise<{ ref: SecretRef; resolvedValue: string }> { + const defaultEnvVar = + params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? ""; + const defaultFilePointer = resolveDefaultFilePointerId(params.provider); + let sourceChoice: SecretRefChoice = "env"; // pragma: allowlist secret + + while (true) { + const sourceRaw: SecretRefChoice = await params.prompter.select({ + message: params.copy?.sourceMessage ?? "Where is this API key stored?", + initialValue: sourceChoice, + options: [ + { + value: "env", + label: "Environment variable", + hint: "Reference a variable from your runtime environment", + }, + { + value: "provider", + label: "Configured secret provider", + hint: "Use a configured file or exec secret provider", + }, + ], + }); + const source: SecretRefChoice = sourceRaw === "provider" ? "provider" : "env"; + sourceChoice = source; + + if (source === "env") { + const envVarRaw = await params.prompter.text({ + message: params.copy?.envVarMessage ?? "Environment variable name", + initialValue: defaultEnvVar || undefined, + placeholder: params.copy?.envVarPlaceholder ?? "OPENAI_API_KEY", + validate: (value) => { + const candidate = value.trim(); + if (!isValidEnvSecretRefId(candidate)) { + return ( + params.copy?.envVarFormatError ?? + 'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).' + ); + } + if (!process.env[candidate]?.trim()) { + return ( + params.copy?.envVarMissingError?.(candidate) ?? + `Environment variable "${candidate}" is missing or empty in this session.` + ); + } + return undefined; + }, + }); + const envCandidate = String(envVarRaw ?? "").trim(); + const envVar = + envCandidate && isValidEnvSecretRefId(envCandidate) ? envCandidate : defaultEnvVar; + if (!envVar) { + throw new Error( + `No valid environment variable name provided for provider "${params.provider}".`, + ); + } + const ref: SecretRef = { + source: "env", + provider: resolveDefaultSecretProviderAlias(params.config, "env", { + preferFirstProviderForSource: true, + }), + id: envVar, + }; + const resolvedValue = await resolveSecretRefString(ref, { + config: params.config, + env: process.env, + }); + await params.prompter.note( + params.copy?.envValidatedMessage?.(envVar) ?? + `Validated environment variable ${envVar}. OpenClaw will store a reference, not the key value.`, + "Reference validated", + ); + return { ref, resolvedValue }; + } + + const externalProviders = Object.entries(params.config.secrets?.providers ?? {}).filter( + ([, provider]) => provider?.source === "file" || provider?.source === "exec", + ); + if (externalProviders.length === 0) { + await params.prompter.note( + params.copy?.noProvidersMessage ?? + "No file/exec secret providers are configured yet. Add one under secrets.providers, or select Environment variable.", + "No providers configured", + ); + continue; + } + const defaultProvider = resolveDefaultSecretProviderAlias(params.config, "file", { + preferFirstProviderForSource: true, + }); + const selectedProvider = await params.prompter.select({ + message: "Select secret provider", + initialValue: + externalProviders.find(([providerName]) => providerName === defaultProvider)?.[0] ?? + externalProviders[0]?.[0], + options: externalProviders.map(([providerName, provider]) => ({ + value: providerName, + label: providerName, + hint: provider?.source === "exec" ? "Exec provider" : "File provider", + })), + }); + const providerEntry = params.config.secrets?.providers?.[selectedProvider]; + if (!providerEntry || (providerEntry.source !== "file" && providerEntry.source !== "exec")) { + await params.prompter.note( + `Provider "${selectedProvider}" is not a file/exec provider.`, + "Invalid provider", + ); + continue; + } + const idPrompt = + providerEntry.source === "file" + ? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)" + : "Secret id for the exec provider"; + const idDefault = + providerEntry.source === "file" + ? providerEntry.mode === "singleValue" + ? "value" + : defaultFilePointer + : `${params.provider}/apiKey`; + const idRaw = await params.prompter.text({ + message: idPrompt, + initialValue: idDefault, + placeholder: providerEntry.source === "file" ? "/providers/openai/apiKey" : "openai/api-key", + validate: (value) => { + const candidate = value.trim(); + if (!candidate) { + return "Secret id cannot be empty."; + } + if ( + providerEntry.source === "file" && + providerEntry.mode !== "singleValue" && + !isValidFileSecretRefId(candidate) + ) { + return 'Use an absolute JSON pointer like "/providers/openai/apiKey".'; + } + if ( + providerEntry.source === "file" && + providerEntry.mode === "singleValue" && + candidate !== "value" + ) { + return 'singleValue mode expects id "value".'; + } + if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) { + return formatExecSecretRefIdValidationMessage(); + } + return undefined; + }, + }); + const id = String(idRaw ?? "").trim() || idDefault; + const ref: SecretRef = { + source: providerEntry.source, + provider: selectedProvider, + id, + }; + try { + const resolvedValue = await resolveSecretRefString(ref, { + config: params.config, + env: process.env, + }); + await params.prompter.note( + params.copy?.providerValidatedMessage?.(selectedProvider, id, providerEntry.source) ?? + `Validated ${providerEntry.source} reference ${selectedProvider}:${id}. OpenClaw will store a reference, not the key value.`, + "Reference validated", + ); + return { ref, resolvedValue }; + } catch (error) { + await params.prompter.note( + [ + `Could not validate provider reference ${selectedProvider}:${id}.`, + formatErrorMessage(error), + "Check your provider configuration and try again.", + ].join("\n"), + "Reference check failed", + ); + } + } +} + +export function normalizeTokenProviderInput( + tokenProvider: string | null | undefined, +): string | undefined { + const normalized = String(tokenProvider ?? "") + .trim() + .toLowerCase(); + return normalized || undefined; +} + +export function normalizeSecretInputModeInput( + secretInputMode: string | null | undefined, +): SecretInputMode | undefined { + const normalized = String(secretInputMode ?? "") + .trim() + .toLowerCase(); + if (normalized === "plaintext" || normalized === "ref") { + return normalized; + } + return undefined; +} + +export async function resolveSecretInputModeForEnvSelection(params: { + prompter: WizardPrompter; + explicitMode?: SecretInputMode; + copy?: SecretInputModePromptCopy; +}): Promise { + if (params.explicitMode) { + return params.explicitMode; + } + if (typeof params.prompter.select !== "function") { + return "plaintext"; + } + const selected = await params.prompter.select({ + message: params.copy?.modeMessage ?? "How do you want to provide this API key?", + initialValue: "plaintext", + options: [ + { + value: "plaintext", + label: params.copy?.plaintextLabel ?? "Paste API key now", + hint: params.copy?.plaintextHint ?? "Stores the key directly in OpenClaw config", + }, + { + value: "ref", + label: params.copy?.refLabel ?? "Use external secret provider", + hint: + params.copy?.refHint ?? + "Stores a reference to env or configured external secret providers", + }, + ], + }); + return selected === "ref" ? "ref" : "plaintext"; +} + +export async function maybeApplyApiKeyFromOption(params: { + token: string | undefined; + tokenProvider: string | undefined; + secretInputMode?: SecretInputMode; + expectedProviders: string[]; + normalize: (value: string) => string; + setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; +}): Promise { + const tokenProvider = normalizeTokenProviderInput(params.tokenProvider); + const expectedProviders = params.expectedProviders + .map((provider) => normalizeTokenProviderInput(provider)) + .filter((provider): provider is string => Boolean(provider)); + if (!params.token || !tokenProvider || !expectedProviders.includes(tokenProvider)) { + return undefined; + } + const apiKey = params.normalize(params.token); + await params.setCredential(apiKey, params.secretInputMode); + return apiKey; +} + +export async function ensureApiKeyFromOptionEnvOrPrompt(params: { + token: string | undefined; + tokenProvider: string | undefined; + secretInputMode?: SecretInputMode; + config: OpenClawConfig; + expectedProviders: string[]; + provider: string; + envLabel: string; + promptMessage: string; + normalize: (value: string) => string; + validate: (value: string) => string | undefined; + prompter: WizardPrompter; + setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; + noteMessage?: string; + noteTitle?: string; +}): Promise { + const optionApiKey = await maybeApplyApiKeyFromOption({ + token: params.token, + tokenProvider: params.tokenProvider, + secretInputMode: params.secretInputMode, + expectedProviders: params.expectedProviders, + normalize: params.normalize, + setCredential: params.setCredential, + }); + if (optionApiKey) { + return optionApiKey; + } + + if (params.noteMessage) { + await params.prompter.note(params.noteMessage, params.noteTitle); + } + + return await ensureApiKeyFromEnvOrPrompt({ + config: params.config, + provider: params.provider, + envLabel: params.envLabel, + promptMessage: params.promptMessage, + normalize: params.normalize, + validate: params.validate, + prompter: params.prompter, + secretInputMode: params.secretInputMode, + setCredential: params.setCredential, + }); +} + +export async function ensureApiKeyFromEnvOrPrompt(params: { + config: OpenClawConfig; + provider: string; + envLabel: string; + promptMessage: string; + normalize: (value: string) => string; + validate: (value: string) => string | undefined; + prompter: WizardPrompter; + secretInputMode?: SecretInputMode; + setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; +}): Promise { + const selectedMode = await resolveSecretInputModeForEnvSelection({ + prompter: params.prompter, + explicitMode: params.secretInputMode, + }); + const envKey = resolveEnvApiKey(params.provider); + + if (selectedMode === "ref") { + if (typeof params.prompter.select !== "function") { + const fallback = resolveRefFallbackInput({ + config: params.config, + provider: params.provider, + preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined, + }); + await params.setCredential(fallback.ref, selectedMode); + return fallback.resolvedValue; + } + const resolved = await promptSecretRefForSetup({ + provider: params.provider, + config: params.config, + prompter: params.prompter, + preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined, + }); + await params.setCredential(resolved.ref, selectedMode); + return resolved.resolvedValue; + } + + if (envKey && selectedMode === "plaintext") { + const useExisting = await params.prompter.confirm({ + message: `Use existing ${params.envLabel} (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await params.setCredential(envKey.apiKey, selectedMode); + return envKey.apiKey; + } + } + + const key = await params.prompter.text({ + message: params.promptMessage, + validate: params.validate, + }); + const apiKey = params.normalize(String(key ?? "")); + await params.setCredential(apiKey, selectedMode); + return apiKey; +} diff --git a/src/plugins/provider-auth-token.ts b/src/plugins/provider-auth-token.ts new file mode 100644 index 00000000000..d003c2aa1b7 --- /dev/null +++ b/src/plugins/provider-auth-token.ts @@ -0,0 +1,38 @@ +import { normalizeProviderId } from "../agents/model-selection.js"; + +export const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-"; +export const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80; +export const DEFAULT_TOKEN_PROFILE_NAME = "default"; + +export function normalizeTokenProfileName(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return DEFAULT_TOKEN_PROFILE_NAME; + } + const slug = trimmed + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || DEFAULT_TOKEN_PROFILE_NAME; +} + +export function buildTokenProfileId(params: { provider: string; name: string }): string { + const provider = normalizeProviderId(params.provider); + const name = normalizeTokenProfileName(params.name); + return `${provider}:${name}`; +} + +export function validateAnthropicSetupToken(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return "Required"; + } + if (!trimmed.startsWith(ANTHROPIC_SETUP_TOKEN_PREFIX)) { + return `Expected token starting with ${ANTHROPIC_SETUP_TOKEN_PREFIX}`; + } + if (trimmed.length < ANTHROPIC_SETUP_TOKEN_MIN_LENGTH) { + return "Token looks too short; paste the full setup-token"; + } + return undefined; +} diff --git a/src/plugins/provider-auth-types.ts b/src/plugins/provider-auth-types.ts new file mode 100644 index 00000000000..c26ba4778d8 --- /dev/null +++ b/src/plugins/provider-auth-types.ts @@ -0,0 +1 @@ +export type SecretInputMode = "plaintext" | "ref"; // pragma: allowlist secret diff --git a/src/plugins/provider-model-allowlist.ts b/src/plugins/provider-model-allowlist.ts new file mode 100644 index 00000000000..bc6dfc5308d --- /dev/null +++ b/src/plugins/provider-model-allowlist.ts @@ -0,0 +1,41 @@ +import { DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { resolveAllowlistModelKey } from "../agents/model-selection.js"; +import type { OpenClawConfig } from "../config/config.js"; + +export function ensureModelAllowlistEntry(params: { + cfg: OpenClawConfig; + modelRef: string; + defaultProvider?: string; +}): OpenClawConfig { + const rawModelRef = params.modelRef.trim(); + if (!rawModelRef) { + return params.cfg; + } + + const models = { ...params.cfg.agents?.defaults?.models }; + const keySet = new Set([rawModelRef]); + const canonicalKey = resolveAllowlistModelKey( + rawModelRef, + params.defaultProvider ?? DEFAULT_PROVIDER, + ); + if (canonicalKey) { + keySet.add(canonicalKey); + } + + for (const key of keySet) { + models[key] = { + ...models[key], + }; + } + + return { + ...params.cfg, + agents: { + ...params.cfg.agents, + defaults: { + ...params.cfg.agents?.defaults, + models, + }, + }, + }; +} diff --git a/src/plugins/provider-model-defaults.ts b/src/plugins/provider-model-defaults.ts new file mode 100644 index 00000000000..60a18c1a759 --- /dev/null +++ b/src/plugins/provider-model-defaults.ts @@ -0,0 +1,81 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { ensureModelAllowlistEntry } from "./provider-model-allowlist.js"; +import { applyAgentDefaultPrimaryModel } from "./provider-model-primary.js"; + +export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview"; +export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.1-codex"; +export const OPENCODE_GO_DEFAULT_MODEL_REF = "opencode-go/kimi-k2.5"; +export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode/claude-opus-4-6"; + +const LEGACY_OPENCODE_ZEN_DEFAULT_MODELS = new Set([ + "opencode/claude-opus-4-5", + "opencode-zen/claude-opus-4-5", +]); + +export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): { + next: OpenClawConfig; + changed: boolean; +} { + return applyAgentDefaultPrimaryModel({ cfg, model: GOOGLE_GEMINI_DEFAULT_MODEL }); +} + +export function applyOpenAIProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = ensureModelAllowlistEntry({ + cfg, + modelRef: OPENAI_DEFAULT_MODEL, + }); + const models = { ...next.agents?.defaults?.models }; + models[OPENAI_DEFAULT_MODEL] = { + ...models[OPENAI_DEFAULT_MODEL], + alias: models[OPENAI_DEFAULT_MODEL]?.alias ?? "GPT", + }; + + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + models, + }, + }, + }; +} + +export function applyOpenAIConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyOpenAIProviderConfig(cfg); + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: + next.agents?.defaults?.model && typeof next.agents.defaults.model === "object" + ? { + ...next.agents.defaults.model, + primary: OPENAI_DEFAULT_MODEL, + } + : { primary: OPENAI_DEFAULT_MODEL }, + }, + }, + }; +} + +export function applyOpencodeGoModelDefault(cfg: OpenClawConfig): { + next: OpenClawConfig; + changed: boolean; +} { + return applyAgentDefaultPrimaryModel({ cfg, model: OPENCODE_GO_DEFAULT_MODEL_REF }); +} + +export function applyOpencodeZenModelDefault(cfg: OpenClawConfig): { + next: OpenClawConfig; + changed: boolean; +} { + return applyAgentDefaultPrimaryModel({ + cfg, + model: OPENCODE_ZEN_DEFAULT_MODEL, + legacyModels: LEGACY_OPENCODE_ZEN_DEFAULT_MODELS, + }); +} diff --git a/src/plugins/provider-model-primary.ts b/src/plugins/provider-model-primary.ts new file mode 100644 index 00000000000..bf4bd8a2fe7 --- /dev/null +++ b/src/plugins/provider-model-primary.ts @@ -0,0 +1,72 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { AgentModelListConfig } from "../config/types.js"; + +export function resolvePrimaryModel(model?: AgentModelListConfig | string): string | undefined { + if (typeof model === "string") { + return model; + } + if (model && typeof model === "object" && typeof model.primary === "string") { + return model.primary; + } + return undefined; +} + +export function applyAgentDefaultPrimaryModel(params: { + cfg: OpenClawConfig; + model: string; + legacyModels?: Set; +}): { next: OpenClawConfig; changed: boolean } { + const current = resolvePrimaryModel(params.cfg.agents?.defaults?.model)?.trim(); + const normalizedCurrent = current && params.legacyModels?.has(current) ? params.model : current; + if (normalizedCurrent === params.model) { + return { next: params.cfg, changed: false }; + } + + return { + next: { + ...params.cfg, + agents: { + ...params.cfg.agents, + defaults: { + ...params.cfg.agents?.defaults, + model: + params.cfg.agents?.defaults?.model && + typeof params.cfg.agents.defaults.model === "object" + ? { + ...params.cfg.agents.defaults.model, + primary: params.model, + } + : { primary: params.model }, + }, + }, + }, + changed: true, + }; +} + +export function applyPrimaryModel(cfg: OpenClawConfig, model: string): OpenClawConfig { + const defaults = cfg.agents?.defaults; + const existingModel = defaults?.model; + const existingModels = defaults?.models; + const fallbacks = + typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel + ? (existingModel as { fallbacks?: string[] }).fallbacks + : undefined; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...defaults, + model: { + ...(fallbacks ? { fallbacks } : undefined), + primary: model, + }, + models: { + ...existingModels, + [model]: existingModels?.[model] ?? {}, + }, + }, + }, + }; +} diff --git a/src/plugins/provider-oauth-flow.ts b/src/plugins/provider-oauth-flow.ts new file mode 100644 index 00000000000..e2ae6717c60 --- /dev/null +++ b/src/plugins/provider-oauth-flow.ts @@ -0,0 +1,53 @@ +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; + +export type OAuthPrompt = { message: string; placeholder?: string }; + +const validateRequiredInput = (value: string) => (value.trim().length > 0 ? undefined : "Required"); + +export function createVpsAwareOAuthHandlers(params: { + isRemote: boolean; + prompter: WizardPrompter; + runtime: RuntimeEnv; + spin: ReturnType; + openUrl: (url: string) => Promise; + localBrowserMessage: string; + manualPromptMessage?: string; +}): { + onAuth: (event: { url: string }) => Promise; + onPrompt: (prompt: OAuthPrompt) => Promise; +} { + const manualPromptMessage = params.manualPromptMessage ?? "Paste the redirect URL"; + let manualCodePromise: Promise | undefined; + + return { + onAuth: async ({ url }) => { + if (params.isRemote) { + params.spin.stop("OAuth URL ready"); + params.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); + manualCodePromise = params.prompter + .text({ + message: manualPromptMessage, + validate: validateRequiredInput, + }) + .then((value) => String(value)); + return; + } + + params.spin.update(params.localBrowserMessage); + await params.openUrl(url); + params.runtime.log(`Open: ${url}`); + }, + onPrompt: async (prompt) => { + if (manualCodePromise) { + return manualCodePromise; + } + const code = await params.prompter.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: validateRequiredInput, + }); + return String(code); + }, + }; +} diff --git a/src/plugins/provider-openai-codex-oauth-tls.ts b/src/plugins/provider-openai-codex-oauth-tls.ts new file mode 100644 index 00000000000..bf9e69b0519 --- /dev/null +++ b/src/plugins/provider-openai-codex-oauth-tls.ts @@ -0,0 +1,164 @@ +import path from "node:path"; +import { formatCliCommand } from "../cli/command-format.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { note } from "../terminal/note.js"; + +const TLS_CERT_ERROR_CODES = new Set([ + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "CERT_HAS_EXPIRED", + "DEPTH_ZERO_SELF_SIGNED_CERT", + "SELF_SIGNED_CERT_IN_CHAIN", + "ERR_TLS_CERT_ALTNAME_INVALID", +]); + +const TLS_CERT_ERROR_PATTERNS = [ + /unable to get local issuer certificate/i, + /unable to verify the first certificate/i, + /self[- ]signed certificate/i, + /certificate has expired/i, +]; + +const OPENAI_AUTH_PROBE_URL = + "https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email"; + +type PreflightFailureKind = "tls-cert" | "network"; + +export type OpenAIOAuthTlsPreflightResult = + | { ok: true } + | { + ok: false; + kind: PreflightFailureKind; + code?: string; + message: string; + }; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function extractFailure(error: unknown): { + code?: string; + message: string; + kind: PreflightFailureKind; +} { + const root = asRecord(error); + const rootCause = asRecord(root?.cause); + const code = typeof rootCause?.code === "string" ? rootCause.code : undefined; + const message = + typeof rootCause?.message === "string" + ? rootCause.message + : typeof root?.message === "string" + ? root.message + : String(error); + const isTlsCertError = + (code ? TLS_CERT_ERROR_CODES.has(code) : false) || + TLS_CERT_ERROR_PATTERNS.some((pattern) => pattern.test(message)); + return { + code, + message, + kind: isTlsCertError ? "tls-cert" : "network", + }; +} + +function resolveHomebrewPrefixFromExecPath(execPath: string): string | null { + const marker = `${path.sep}Cellar${path.sep}`; + const idx = execPath.indexOf(marker); + if (idx > 0) { + return execPath.slice(0, idx); + } + const envPrefix = process.env.HOMEBREW_PREFIX?.trim(); + return envPrefix ? envPrefix : null; +} + +function resolveCertBundlePath(): string | null { + const prefix = resolveHomebrewPrefixFromExecPath(process.execPath); + if (!prefix) { + return null; + } + return path.join(prefix, "etc", "openssl@3", "cert.pem"); +} + +function hasOpenAICodexOAuthProfile(cfg: OpenClawConfig): boolean { + const profiles = cfg.auth?.profiles; + if (!profiles) { + return false; + } + return Object.values(profiles).some( + (profile) => profile.provider === "openai-codex" && profile.mode === "oauth", + ); +} + +function shouldRunOpenAIOAuthTlsPrerequisites(params: { + cfg: OpenClawConfig; + deep?: boolean; +}): boolean { + if (params.deep === true) { + return true; + } + return hasOpenAICodexOAuthProfile(params.cfg); +} + +export async function runOpenAIOAuthTlsPreflight(options?: { + timeoutMs?: number; + fetchImpl?: typeof fetch; +}): Promise { + const timeoutMs = options?.timeoutMs ?? 5000; + const fetchImpl = options?.fetchImpl ?? fetch; + try { + await fetchImpl(OPENAI_AUTH_PROBE_URL, { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(timeoutMs), + }); + return { ok: true }; + } catch (error) { + const failure = extractFailure(error); + return { + ok: false, + kind: failure.kind, + code: failure.code, + message: failure.message, + }; + } +} + +export function formatOpenAIOAuthTlsPreflightFix( + result: Exclude, +): string { + if (result.kind !== "tls-cert") { + return [ + "OpenAI OAuth prerequisites check failed due to a network error before the browser flow.", + `Cause: ${result.message}`, + "Verify DNS/firewall/proxy access to auth.openai.com and retry.", + ].join("\n"); + } + const certBundlePath = resolveCertBundlePath(); + const lines = [ + "OpenAI OAuth prerequisites check failed: Node/OpenSSL cannot validate TLS certificates.", + `Cause: ${result.code ? `${result.code} (${result.message})` : result.message}`, + "", + "Fix (Homebrew Node/OpenSSL):", + `- ${formatCliCommand("brew postinstall ca-certificates")}`, + `- ${formatCliCommand("brew postinstall openssl@3")}`, + ]; + if (certBundlePath) { + lines.push(`- Verify cert bundle exists: ${certBundlePath}`); + } + lines.push("- Retry the OAuth login flow."); + return lines.join("\n"); +} + +export async function noteOpenAIOAuthTlsPrerequisites(params: { + cfg: OpenClawConfig; + deep?: boolean; +}): Promise { + if (!shouldRunOpenAIOAuthTlsPrerequisites(params)) { + return; + } + const result = await runOpenAIOAuthTlsPreflight({ timeoutMs: 4000 }); + if (result.ok || result.kind !== "tls-cert") { + return; + } + note(formatOpenAIOAuthTlsPreflightFix(result), "OAuth TLS prerequisites"); +} diff --git a/src/plugins/provider-openai-codex-oauth.ts b/src/plugins/provider-openai-codex-oauth.ts new file mode 100644 index 00000000000..6e16cf863f0 --- /dev/null +++ b/src/plugins/provider-openai-codex-oauth.ts @@ -0,0 +1,65 @@ +import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai/oauth"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; +import { + formatOpenAIOAuthTlsPreflightFix, + runOpenAIOAuthTlsPreflight, +} from "./provider-openai-codex-oauth-tls.js"; + +export async function loginOpenAICodexOAuth(params: { + prompter: WizardPrompter; + runtime: RuntimeEnv; + isRemote: boolean; + openUrl: (url: string) => Promise; + localBrowserMessage?: string; +}): Promise { + const { prompter, runtime, isRemote, openUrl, localBrowserMessage } = params; + const preflight = await runOpenAIOAuthTlsPreflight(); + if (!preflight.ok && preflight.kind === "tls-cert") { + const hint = formatOpenAIOAuthTlsPreflightFix(preflight); + runtime.error(hint); + await prompter.note(hint, "OAuth prerequisites"); + throw new Error(preflight.message); + } + + await prompter.note( + isRemote + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, paste the redirect URL back here.", + ].join("\n") + : [ + "Browser will open for OpenAI authentication.", + "If the callback doesn't auto-complete, paste the redirect URL.", + "OpenAI OAuth uses localhost:1455 for the callback.", + ].join("\n"), + "OpenAI Codex OAuth", + ); + + const spin = prompter.progress("Starting OAuth flow…"); + try { + const { onAuth: baseOnAuth, onPrompt } = createVpsAwareOAuthHandlers({ + isRemote, + prompter, + runtime, + spin, + openUrl, + localBrowserMessage: localBrowserMessage ?? "Complete sign-in in browser…", + }); + + const creds = await loginOpenAICodex({ + onAuth: baseOnAuth, + onPrompt, + onProgress: (msg: string) => spin.update(msg), + }); + spin.stop("OpenAI OAuth complete"); + return creds ?? null; + } catch (err) { + spin.stop("OpenAI OAuth failed"); + runtime.error(String(err)); + await prompter.note("Trouble with OAuth? See https://docs.openclaw.ai/start/faq", "OAuth help"); + throw err; + } +} diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 23e761940df..52cb2787977 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -17,8 +17,6 @@ import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js"; -import type { createVpsAwareOAuthHandlers } from "../commands/oauth-flow.js"; -import type { OnboardOptions } from "../commands/onboard-types.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderConfig } from "../config/types.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; @@ -39,11 +37,20 @@ import type { SpeechVoiceOption, } from "../tts/provider-types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import type { SecretInputMode } from "./provider-auth-types.js"; +import type { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; import type { PluginRuntime } from "./runtime/types.js"; export type { PluginRuntime } from "./runtime/types.js"; export type { AnyAgentTool } from "../agents/tools/common.js"; +export type ProviderAuthOptionBag = { + token?: string; + tokenProvider?: string; + secretInputMode?: SecretInputMode; + [key: string]: unknown; +}; + export type PluginLogger = { debug?: (message: string) => void; info: (message: string) => void; @@ -144,7 +151,7 @@ export type ProviderAuthContext = { * `--token/--token-provider` pairs. Direct `models auth login` usually * leaves this undefined. */ - opts?: Partial; + opts?: ProviderAuthOptionBag; /** * Onboarding secret persistence preference. * @@ -152,7 +159,7 @@ export type ProviderAuthContext = { * plaintext or env/file/exec ref storage. Ad-hoc `models auth login` flows * usually leave it undefined. */ - secretInputMode?: OnboardOptions["secretInputMode"]; + secretInputMode?: SecretInputMode; /** * Whether the provider auth flow should offer the onboarding secret-storage * mode picker when `secretInputMode` is unset. @@ -196,7 +203,7 @@ export type ProviderAuthMethodNonInteractiveContext = { authChoice: string; config: OpenClawConfig; baseConfig: OpenClawConfig; - opts: OnboardOptions; + opts: ProviderAuthOptionBag; runtime: RuntimeEnv; agentDir?: string; workspaceDir?: string; diff --git a/src/wizard/setup.gateway-config.ts b/src/wizard/setup.gateway-config.ts index 74420c1dac2..ae6c9e42c6f 100644 --- a/src/wizard/setup.gateway-config.ts +++ b/src/wizard/setup.gateway-config.ts @@ -1,7 +1,3 @@ -import { - promptSecretRefForSetup, - resolveSecretInputModeForEnvSelection, -} from "../commands/auth-choice.apply-helpers.js"; import { normalizeGatewayTokenInput, randomToken, @@ -23,6 +19,10 @@ import { } from "../gateway/gateway-config-prompts.shared.js"; import { DEFAULT_DANGEROUS_NODE_COMMANDS } from "../gateway/node-command-policy.js"; import { findTailscaleBinary } from "../infra/tailscale.js"; +import { + promptSecretRefForSetup, + resolveSecretInputModeForEnvSelection, +} from "../plugins/provider-auth-input.js"; import type { RuntimeEnv } from "../runtime.js"; import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; import type { WizardPrompter } from "./prompts.js"; From 68d2bd27c9a7c1d27daf4ba68ed51b2fc0ec53aa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 21:08:55 -0700 Subject: [PATCH 099/128] Plugins: reject conflicting native command aliases --- src/plugins/commands.test.ts | 44 +++++++++++++++++++ src/plugins/commands.ts | 84 +++++++++++++++++++++++------------- 2 files changed, 99 insertions(+), 29 deletions(-) diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index d41841be380..c1c482e2bd2 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -131,6 +131,50 @@ describe("registerPluginCommand", () => { }); }); + it("rejects provider aliases that collide with another registered command", () => { + expect( + registerPluginCommand("demo-plugin", { + name: "voice", + nativeNames: { + telegram: "pair_device", + }, + description: "Voice command", + handler: async () => ({ text: "ok" }), + }), + ).toEqual({ ok: true }); + + expect( + registerPluginCommand("other-plugin", { + name: "pair", + nativeNames: { + telegram: "pair_device", + }, + description: "Pair command", + handler: async () => ({ text: "ok" }), + }), + ).toEqual({ + ok: false, + error: 'Command "pair_device" already registered by plugin "demo-plugin"', + }); + }); + + it("rejects reserved provider aliases", () => { + expect( + registerPluginCommand("demo-plugin", { + name: "voice", + nativeNames: { + telegram: "help", + }, + description: "Voice command", + handler: async () => ({ text: "ok" }), + }), + ).toEqual({ + ok: false, + error: + 'Native command alias "telegram" invalid: Command name "help" is reserved by a built-in command', + }); + }); + it("resolves Discord DM command bindings with the user target prefix intact", () => { expect( __testing.resolveBindingConversationFromCommand({ diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 945d5cbfb15..b16b3aef4ed 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -130,7 +130,38 @@ export function validatePluginCommandDefinition( if (!command.description.trim()) { return "Command description cannot be empty"; } - return validateCommandName(command.name.trim()); + const nameError = validateCommandName(command.name.trim()); + if (nameError) { + return nameError; + } + for (const [label, alias] of Object.entries(command.nativeNames ?? {})) { + if (typeof alias !== "string") { + continue; + } + const aliasError = validateCommandName(alias.trim()); + if (aliasError) { + return `Native command alias "${label}" invalid: ${aliasError}`; + } + } + return null; +} + +function listPluginInvocationKeys(command: OpenClawPluginCommandDefinition): string[] { + const keys = new Set(); + const push = (value: string | undefined) => { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return; + } + keys.add(`/${normalized}`); + }; + + push(command.name); + push(command.nativeNames?.default); + push(command.nativeNames?.telegram); + push(command.nativeNames?.discord); + + return [...keys]; } /** @@ -154,22 +185,31 @@ export function registerPluginCommand( const name = command.name.trim(); const description = command.description.trim(); - - const key = `/${name.toLowerCase()}`; - - // Check for duplicate registration - if (pluginCommands.has(key)) { - const existing = pluginCommands.get(key)!; - return { - ok: false, - error: `Command "${name}" already registered by plugin "${existing.pluginId}"`, - }; - } - - pluginCommands.set(key, { + const normalizedCommand = { ...command, name, description, + }; + const invocationKeys = listPluginInvocationKeys(normalizedCommand); + const key = `/${name.toLowerCase()}`; + + // Check for duplicate registration + for (const invocationKey of invocationKeys) { + const existing = + pluginCommands.get(invocationKey) ?? + Array.from(pluginCommands.values()).find((candidate) => + listPluginInvocationKeys(candidate).includes(invocationKey), + ); + if (existing) { + return { + ok: false, + error: `Command "${invocationKey.slice(1)}" already registered by plugin "${existing.pluginId}"`, + }; + } + } + + pluginCommands.set(key, { + ...normalizedCommand, pluginId, pluginName: opts?.pluginName, pluginRoot: opts?.pluginRoot, @@ -463,21 +503,7 @@ function resolvePluginNativeName( } function listPluginInvocationNames(command: OpenClawPluginCommandDefinition): string[] { - const names = new Set(); - const push = (value: string | undefined) => { - const normalized = value?.trim().toLowerCase(); - if (!normalized) { - return; - } - names.add(`/${normalized}`); - }; - - push(command.name); - push(command.nativeNames?.default); - push(command.nativeNames?.telegram); - push(command.nativeNames?.discord); - - return [...names]; + return listPluginInvocationKeys(command); } /** From 21f5675f031556eb4db447a5c937754c1bf1c8f1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 21:26:35 -0700 Subject: [PATCH 100/128] Setup: trim channel setup import cycles --- extensions/discord/src/account-inspect.ts | 4 ++-- extensions/discord/src/accounts.ts | 9 +++------ extensions/discord/src/setup-core.ts | 2 +- extensions/discord/src/setup-surface.ts | 2 +- extensions/imessage/src/accounts.ts | 6 +++--- extensions/imessage/src/setup-core.ts | 2 +- extensions/imessage/src/setup-surface.ts | 4 ++-- extensions/signal/src/accounts.ts | 6 +++--- extensions/signal/src/plugin-shared.ts | 5 +++-- extensions/signal/src/setup-core.ts | 4 ++-- extensions/signal/src/setup-surface.ts | 9 +++++---- extensions/slack/src/account-inspect.ts | 6 +++--- extensions/slack/src/accounts.ts | 6 +++--- extensions/slack/src/setup-core.ts | 2 +- extensions/slack/src/setup-surface.ts | 2 +- extensions/telegram/src/setup-core.ts | 4 ++-- extensions/whatsapp/src/setup-surface.ts | 6 +++--- src/plugin-sdk-internal/discord.ts | 3 +-- src/plugin-sdk-internal/imessage.ts | 3 +-- src/plugin-sdk-internal/setup.ts | 2 -- src/plugin-sdk-internal/signal.ts | 3 +-- src/plugin-sdk-internal/slack.ts | 3 +-- 22 files changed, 43 insertions(+), 50 deletions(-) diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index c74c630cee4..3109a0f9bde 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -1,3 +1,4 @@ +import type { DiscordAccountConfig } from "../../../src/config/types.js"; import { hasConfiguredSecretInput, normalizeSecretInputString, @@ -6,8 +7,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig, - type DiscordAccountConfig, -} from "openclaw/plugin-sdk/discord"; +} from "../../../src/plugin-sdk-internal/accounts.js"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId, diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index b9b8ede5fe1..f9984272bcd 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -1,14 +1,11 @@ +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordAccountConfig, DiscordActionConfig } from "../../../src/config/types.js"; import { createAccountActionGate, createAccountListHelpers, normalizeAccountId, resolveAccountEntry, -} from "openclaw/plugin-sdk/account-resolution"; -import type { - DiscordAccountConfig, - DiscordActionConfig, - OpenClawConfig, -} from "openclaw/plugin-sdk/discord"; +} from "../../../src/plugin-sdk-internal/accounts.js"; import { resolveDiscordToken } from "./token.js"; export type ResolvedDiscordAccount = { diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index a362824a0f3..7cdf9aa2434 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -2,7 +2,6 @@ import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - formatDocsLink, migrateBaseNameToDefaultAccount, normalizeAccountId, noteChannelLookupFailure, @@ -18,6 +17,7 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "../../../src/terminal/links.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 da87bfd77d0..be5a374d0fa 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,6 +1,5 @@ import { DEFAULT_ACCOUNT_ID, - formatDocsLink, noteChannelLookupFailure, noteChannelLookupSummary, type OpenClawConfig, @@ -13,6 +12,7 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupDmPolicy, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, diff --git a/extensions/imessage/src/accounts.ts b/extensions/imessage/src/accounts.ts index 5ee90339aa8..8ebbe9d8ffc 100644 --- a/extensions/imessage/src/accounts.ts +++ b/extensions/imessage/src/accounts.ts @@ -1,10 +1,10 @@ +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { IMessageAccountConfig } from "../../../src/config/types.js"; import { createAccountListHelpers, normalizeAccountId, resolveAccountEntry, - type OpenClawConfig, -} from "openclaw/plugin-sdk/account-resolution"; -import type { IMessageAccountConfig } from "openclaw/plugin-sdk/imessage"; +} from "../../../src/plugin-sdk-internal/accounts.js"; export type ResolvedIMessageAccount = { accountId: string; diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index eed33e64192..2560c1cb919 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,7 +1,6 @@ import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - formatDocsLink, migrateBaseNameToDefaultAccount, normalizeAccountId, parseSetupEntriesAllowingWildcard, @@ -16,6 +15,7 @@ import type { ChannelSetupDmPolicy, ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 48c9f130355..2d66c4ab6b2 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,7 +1,6 @@ +import { detectBinary } from "../../../src/commands/onboard-helpers.js"; import { DEFAULT_ACCOUNT_ID, - detectBinary, - formatDocsLink, type OpenClawConfig, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, @@ -10,6 +9,7 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, diff --git a/extensions/signal/src/accounts.ts b/extensions/signal/src/accounts.ts index 456db907685..9699f9394f4 100644 --- a/extensions/signal/src/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -1,10 +1,10 @@ +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { SignalAccountConfig } from "../../../src/config/types.js"; import { createAccountListHelpers, normalizeAccountId, resolveAccountEntry, - type OpenClawConfig, -} from "openclaw/plugin-sdk/account-resolution"; -import type { SignalAccountConfig } from "openclaw/plugin-sdk/signal"; +} from "../../../src/plugin-sdk-internal/accounts.js"; export type ResolvedSignalAccount = { accountId: string; diff --git a/extensions/signal/src/plugin-shared.ts b/extensions/signal/src/plugin-shared.ts index a5713e4c361..8755caf240f 100644 --- a/extensions/signal/src/plugin-shared.ts +++ b/extensions/signal/src/plugin-shared.ts @@ -1,5 +1,6 @@ -import { createScopedAccountConfigAccessors } from "openclaw/plugin-sdk/channel-config-helpers"; -import { normalizeE164, type OpenClawConfig } from "openclaw/plugin-sdk/signal"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { createScopedAccountConfigAccessors } from "../../../src/plugin-sdk-internal/channel-config.js"; +import { normalizeE164 } from "../../../src/utils.js"; import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; import { createSignalSetupWizardProxy } from "./setup-core.js"; diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 1e479c38dc6..9b487ead841 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,8 +1,7 @@ +import { formatCliCommand } from "../../../src/cli/command-format.js"; import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - formatCliCommand, - formatDocsLink, migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeE164, @@ -18,6 +17,7 @@ import type { ChannelSetupDmPolicy, ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 72b1a4ef958..3e2f39cde2d 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,8 +1,8 @@ +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 { - detectBinary, - formatCliCommand, - formatDocsLink, - installSignalCli, + DEFAULT_ACCOUNT_ID, type OpenClawConfig, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, @@ -10,6 +10,7 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, diff --git a/extensions/slack/src/account-inspect.ts b/extensions/slack/src/account-inspect.ts index 7ea7ef042c2..0606a16b0bc 100644 --- a/extensions/slack/src/account-inspect.ts +++ b/extensions/slack/src/account-inspect.ts @@ -1,13 +1,13 @@ import { hasConfiguredSecretInput, normalizeSecretInputString, -} from "openclaw/plugin-sdk/config-runtime"; +} from "../../../src/config/types.secrets.js"; +import type { SlackAccountConfig } from "../../../src/config/types.slack.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig, - type SlackAccountConfig, -} from "openclaw/plugin-sdk/slack"; +} from "../../../src/plugin-sdk-internal/accounts.js"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { mergeSlackAccountConfig, diff --git a/extensions/slack/src/accounts.ts b/extensions/slack/src/accounts.ts index e453067e485..7a1c25845ae 100644 --- a/extensions/slack/src/accounts.ts +++ b/extensions/slack/src/accounts.ts @@ -1,12 +1,12 @@ +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { SlackAccountConfig } from "../../../src/config/types.slack.js"; import { createAccountListHelpers, DEFAULT_ACCOUNT_ID, normalizeAccountId, normalizeChatType, resolveAccountEntry, - type OpenClawConfig, -} from "openclaw/plugin-sdk/account-resolution"; -import type { SlackAccountConfig } from "openclaw/plugin-sdk/slack"; +} 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/setup-core.ts b/extensions/slack/src/setup-core.ts index 2b3753a3c6d..8fc53239c81 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,7 +1,6 @@ import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - formatDocsLink, hasConfiguredSecretInput, migrateBaseNameToDefaultAccount, normalizeAccountId, @@ -20,6 +19,7 @@ import { type ChannelSetupWizard, type ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; import { diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index 1dbfa4f02ce..4e3670ac843 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,6 +1,5 @@ import { DEFAULT_ACCOUNT_ID, - formatDocsLink, hasConfiguredSecretInput, noteChannelLookupFailure, noteChannelLookupSummary, @@ -20,6 +19,7 @@ import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index 13fb01f3a51..896b3b98f04 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,8 +1,7 @@ +import { formatCliCommand } from "../../../src/cli/command-format.js"; import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - formatCliCommand, - formatDocsLink, migrateBaseNameToDefaultAccount, normalizeAccountId, patchChannelConfigForAccount, @@ -12,6 +11,7 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; import { fetchTelegramChatId } from "./api-fetch.js"; diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index bb87fc5b962..be314af285d 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -1,8 +1,8 @@ import path from "node:path"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import type { DmPolicy } from "../../../src/config/types.js"; import { DEFAULT_ACCOUNT_ID, - formatCliCommand, - formatDocsLink, normalizeAccountId, normalizeAllowFromEntries, normalizeE164, @@ -12,7 +12,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { type DmPolicy } from "openclaw/plugin-sdk/whatsapp"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; import { loginWeb } from "./login.js"; import { whatsappSetupAdapter } from "./setup-core.js"; diff --git a/src/plugin-sdk-internal/discord.ts b/src/plugin-sdk-internal/discord.ts index 9a29900c717..b978b678e9d 100644 --- a/src/plugin-sdk-internal/discord.ts +++ b/src/plugin-sdk-internal/discord.ts @@ -46,8 +46,7 @@ export { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { discordSetupWizard } from "../../extensions/discord/src/setup-surface.js"; -export { discordSetupAdapter } from "../../extensions/discord/src/setup-core.js"; +export { discordSetupWizard } from "../../extensions/discord/src/plugin-shared.js"; export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; export { diff --git a/src/plugin-sdk-internal/imessage.ts b/src/plugin-sdk-internal/imessage.ts index 757885fc616..ec338483b98 100644 --- a/src/plugin-sdk-internal/imessage.ts +++ b/src/plugin-sdk-internal/imessage.ts @@ -39,8 +39,7 @@ export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { imessageSetupWizard } from "../../extensions/imessage/src/setup-surface.js"; -export { imessageSetupAdapter } from "../../extensions/imessage/src/setup-core.js"; +export { imessageSetupWizard } from "../../extensions/imessage/src/plugin-shared.js"; export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; diff --git a/src/plugin-sdk-internal/setup.ts b/src/plugin-sdk-internal/setup.ts index c035d40376a..d012e201bd8 100644 --- a/src/plugin-sdk-internal/setup.ts +++ b/src/plugin-sdk-internal/setup.ts @@ -30,8 +30,6 @@ export { 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"; diff --git a/src/plugin-sdk-internal/signal.ts b/src/plugin-sdk-internal/signal.ts index 6b938e66518..237298f9111 100644 --- a/src/plugin-sdk-internal/signal.ts +++ b/src/plugin-sdk-internal/signal.ts @@ -25,8 +25,7 @@ export { resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; export { evaluateSenderGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; -export { signalSetupWizard } from "../../extensions/signal/src/setup-surface.js"; -export { signalSetupAdapter } from "../../extensions/signal/src/setup-core.js"; +export { signalSetupWizard } from "../../extensions/signal/src/plugin-shared.js"; export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; export { normalizeE164 } from "../utils.js"; diff --git a/src/plugin-sdk-internal/slack.ts b/src/plugin-sdk-internal/slack.ts index abde5688cdb..c375010a9de 100644 --- a/src/plugin-sdk-internal/slack.ts +++ b/src/plugin-sdk-internal/slack.ts @@ -61,8 +61,7 @@ export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { slackSetupAdapter } from "../../extensions/slack/src/setup-core.js"; -export { slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; +export { slackSetupWizard } from "../../extensions/slack/src/plugin-shared.js"; export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; export { handleSlackMessageAction } from "../plugin-sdk/slack-message-actions.js"; From 7fa3825e80cb888c58b116066cda3ca870802a9c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:59:30 -0700 Subject: [PATCH 101/128] feat(plugins): derive bundled web search providers from plugins --- src/plugins/contracts/loader.contract.test.ts | 73 +++--- src/plugins/providers.ts | 5 + src/plugins/web-search-providers.ts | 248 ++++++++---------- 3 files changed, 142 insertions(+), 184 deletions(-) diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index 874a94a0b5e..740366394a6 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -1,36 +1,27 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withBundledPluginAllowlistCompat } from "../bundled-compat.js"; +import { __testing as providerTesting } from "../providers.js"; +import { resolvePluginWebSearchProviders } from "../web-search-providers.js"; import { providerContractRegistry, webSearchProviderContractRegistry } from "./registry.js"; -const loadOpenClawPluginsMock = vi.fn(); - -vi.mock("../loader.js", () => ({ - loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), -})); - -const { resolvePluginProviders } = await import("../providers.js"); -const { resolvePluginWebSearchProviders } = await import("../web-search-providers.js"); - function uniqueSortedPluginIds(values: string[]) { return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); } +function normalizeProviderContractPluginId(pluginId: string) { + return pluginId === "kimi-coding" ? "kimi" : pluginId; +} + describe("plugin loader contract", () => { beforeEach(() => { - loadOpenClawPluginsMock.mockReset(); - loadOpenClawPluginsMock.mockReturnValue({ - providers: [], - mediaUnderstandingProviders: [], - webSearchProviders: [], - }); + vi.restoreAllMocks(); }); it("keeps bundled provider compatibility wired to the provider registry", () => { const providerPluginIds = uniqueSortedPluginIds( - providerContractRegistry.map((entry) => entry.pluginId), + providerContractRegistry.map((entry) => normalizeProviderContractPluginId(entry.pluginId)), ); - - resolvePluginProviders({ - bundledProviderAllowlistCompat: true, + const compatPluginIds = providerTesting.resolveBundledProviderCompatPluginIds({ config: { plugins: { allow: ["openrouter"], @@ -38,37 +29,35 @@ describe("plugin loader contract", () => { }, }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: expect.arrayContaining(providerPluginIds), - }), - }), - }), + const compatConfig = withBundledPluginAllowlistCompat({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + pluginIds: compatPluginIds, + }); + + expect(uniqueSortedPluginIds(compatPluginIds)).toEqual( + expect.arrayContaining(providerPluginIds), ); + expect(compatConfig?.plugins?.allow).toEqual(expect.arrayContaining(providerPluginIds)); }); it("keeps vitest bundled provider enablement wired to the provider registry", () => { const providerPluginIds = uniqueSortedPluginIds( - providerContractRegistry.map((entry) => entry.pluginId), + providerContractRegistry.map((entry) => normalizeProviderContractPluginId(entry.pluginId)), ); - - resolvePluginProviders({ - bundledProviderVitestCompat: true, + const compatConfig = providerTesting.withBundledProviderVitestCompat({ + config: undefined, + pluginIds: providerPluginIds, env: { VITEST: "1" } as NodeJS.ProcessEnv, }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - enabled: true, - allow: expect.arrayContaining(providerPluginIds), - }), - }), - }), - ); + expect(compatConfig?.plugins).toMatchObject({ + enabled: true, + allow: expect.arrayContaining(providerPluginIds), + }); }); it("keeps bundled web search loading scoped to the web search registry", () => { @@ -81,7 +70,6 @@ describe("plugin loader contract", () => { 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", () => { @@ -101,6 +89,5 @@ describe("plugin loader contract", () => { expect(uniqueSortedPluginIds(providers.map((provider) => provider.pluginId))).toEqual( webSearchPluginIds, ); - expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 35ef2703553..45c84986e6c 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -82,6 +82,11 @@ function resolveBundledProviderCompatPluginIds(params: { .toSorted((left, right) => left.localeCompare(right)); } +export const __testing = { + resolveBundledProviderCompatPluginIds, + withBundledProviderVitestCompat, +} as const; + export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions["config"]; diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 8aba087f1fc..9ecdef1fd3c 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -1,148 +1,37 @@ -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 bravePlugin from "../../extensions/brave/index.js"; +import firecrawlPlugin from "../../extensions/firecrawl/index.js"; +import googlePlugin from "../../extensions/google/index.js"; +import moonshotPlugin from "../../extensions/moonshot/index.js"; +import perplexityPlugin from "../../extensions/perplexity/index.js"; +import xaiPlugin from "../../extensions/xai/index.js"; import { withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, } from "./bundled-compat.js"; -import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; +import type { PluginWebSearchProviderRegistration } from "./registry.js"; import { getActivePluginRegistry } from "./runtime.js"; +import type { OpenClawPluginApi, WebSearchProviderPlugin } from "./types.js"; import type { PluginWebSearchProviderEntry } from "./types.js"; -const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ - "brave", - "firecrawl", - "google", - "moonshot", - "perplexity", - "xai", -] as const; +type RegistrablePlugin = { + id: string; + name: string; + register: (api: OpenClawPluginApi) => void; +}; -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; +const BUNDLED_WEB_SEARCH_PLUGINS: readonly RegistrablePlugin[] = [ + bravePlugin, + firecrawlPlugin, + googlePlugin, + moonshotPlugin, + perplexityPlugin, + xaiPlugin, +]; -export function resolvePluginWebSearchProviders(params: { - config?: PluginLoadOptions["config"]; - workspaceDir?: string; - env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; -}): PluginWebSearchProviderEntry[] { - const allowlistCompat = params.bundledAllowlistCompat - ? withBundledPluginAllowlistCompat({ - config: params.config, - pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, - }) - : params.config; - const config = withBundledPluginEnablementCompat({ - config: allowlistCompat, - pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, - }); - const normalizedPlugins = normalizePluginsConfig(config?.plugins); - - return sortWebSearchProviders( - BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY.filter( - ({ pluginId }) => - resolveEffectiveEnableState({ - id: pluginId, - origin: "bundled", - config: normalizedPlugins, - rootConfig: config, - }).enabled, - ).map((entry) => ({ - ...entry.provider, - pluginId: entry.pluginId, - })), - ); -} +const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = BUNDLED_WEB_SEARCH_PLUGINS.map( + (plugin) => plugin.id, +); function sortWebSearchProviders( providers: PluginWebSearchProviderEntry[], @@ -157,18 +46,95 @@ function sortWebSearchProviders( }); } +function mapWebSearchProviderEntries( + entries: PluginWebSearchProviderRegistration[], +): PluginWebSearchProviderEntry[] { + return sortWebSearchProviders( + entries.map((entry) => ({ + ...entry.provider, + pluginId: entry.pluginId, + })), + ); +} + +function normalizeWebSearchPluginConfig(params: { + config?: PluginLoadOptions["config"]; + bundledAllowlistCompat?: boolean; +}): PluginLoadOptions["config"] { + const allowlistCompat = params.bundledAllowlistCompat + ? withBundledPluginAllowlistCompat({ + config: params.config, + pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, + }) + : params.config; + return withBundledPluginEnablementCompat({ + config: allowlistCompat, + pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, + }); +} + +function captureBundledWebSearchProviders( + plugin: RegistrablePlugin, +): PluginWebSearchProviderRegistration[] { + const providers: WebSearchProviderPlugin[] = []; + const api = { + registerProvider() {}, + registerSpeechProvider() {}, + registerMediaUnderstandingProvider() {}, + registerWebSearchProvider(provider: WebSearchProviderPlugin) { + providers.push(provider); + }, + registerTool() {}, + }; + plugin.register(api as unknown as OpenClawPluginApi); + return providers.map((provider) => ({ + pluginId: plugin.id, + pluginName: plugin.name, + provider, + source: "bundled", + })); +} + +function resolveBundledWebSearchRegistrations(params: { + config?: PluginLoadOptions["config"]; + bundledAllowlistCompat?: boolean; +}): PluginWebSearchProviderRegistration[] { + const config = normalizeWebSearchPluginConfig(params); + if (config?.plugins?.enabled === false) { + return []; + } + const allowlist = config?.plugins?.allow + ? new Set(config.plugins.allow.map((entry) => entry.trim()).filter(Boolean)) + : null; + return BUNDLED_WEB_SEARCH_PLUGINS.flatMap((plugin) => { + if (allowlist && !allowlist.has(plugin.id)) { + return []; + } + if (config?.plugins?.entries?.[plugin.id]?.enabled === false) { + return []; + } + return captureBundledWebSearchProviders(plugin); + }); +} + +export function resolvePluginWebSearchProviders(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; + bundledAllowlistCompat?: boolean; +}): PluginWebSearchProviderEntry[] { + return mapWebSearchProviderEntries(resolveBundledWebSearchRegistrations(params)); +} + export function resolveRuntimeWebSearchProviders(params: { config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; bundledAllowlistCompat?: boolean; }): PluginWebSearchProviderEntry[] { const runtimeProviders = getActivePluginRegistry()?.webSearchProviders ?? []; if (runtimeProviders.length > 0) { - return sortWebSearchProviders( - runtimeProviders.map((entry) => ({ - ...entry.provider, - pluginId: entry.pluginId, - })), - ); + return mapWebSearchProviderEntries(runtimeProviders); } return resolvePluginWebSearchProviders(params); } From 50c3321d2e758dc1516bc6f31fea860f1de8927f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:59:39 -0700 Subject: [PATCH 102/128] feat(media): route image tool through media providers --- extensions/anthropic/index.ts | 2 + .../anthropic/media-understanding-provider.ts | 2 + .../google/media-understanding-provider.ts | 2 + extensions/minimax/index.ts | 6 + .../minimax/media-understanding-provider.ts | 3 + extensions/mistral/index.ts | 2 + extensions/moonshot/index.ts | 2 + .../moonshot/media-understanding-provider.ts | 8 +- extensions/openai/index.ts | 2 + .../openai/media-understanding-provider.ts | 2 + extensions/zai/index.ts | 2 + .../zai/media-understanding-provider.ts | 2 + src/agents/tools/image-tool.test.ts | 13 +- src/agents/tools/image-tool.ts | 133 ++++++------- .../providers/image.test.ts | 20 +- src/media-understanding/providers/image.ts | 188 ++++++++++++++++-- src/media-understanding/providers/index.ts | 33 ++- src/media-understanding/runner.ts | 3 +- src/media-understanding/runtime.ts | 2 +- src/media-understanding/types.ts | 25 +++ src/plugin-sdk/media-understanding.ts | 5 +- .../contracts/registry.contract.test.ts | 31 +++ 22 files changed, 382 insertions(+), 106 deletions(-) diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index 25cb604dbcb..4cad353908b 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -28,6 +28,7 @@ import { } from "openclaw/plugin-sdk/provider-auth"; import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage"; +import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js"; const PROVIDER_ID = "anthropic"; const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6"; @@ -396,6 +397,7 @@ const anthropicPlugin = { profileId: ctx.profileId, }), }); + api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider); }, }; diff --git a/extensions/anthropic/media-understanding-provider.ts b/extensions/anthropic/media-understanding-provider.ts index 5b1f0711705..68a95c93546 100644 --- a/extensions/anthropic/media-understanding-provider.ts +++ b/extensions/anthropic/media-understanding-provider.ts @@ -1,5 +1,6 @@ import { describeImageWithModel, + describeImagesWithModel, type MediaUnderstandingProvider, } from "openclaw/plugin-sdk/media-understanding"; @@ -7,4 +8,5 @@ export const anthropicMediaUnderstandingProvider: MediaUnderstandingProvider = { id: "anthropic", capabilities: ["image"], describeImage: describeImageWithModel, + describeImages: describeImagesWithModel, }; diff --git a/extensions/google/media-understanding-provider.ts b/extensions/google/media-understanding-provider.ts index a64f26ca6c8..97b008ee578 100644 --- a/extensions/google/media-understanding-provider.ts +++ b/extensions/google/media-understanding-provider.ts @@ -2,6 +2,7 @@ import { normalizeGoogleModelId, parseGeminiAuth } from "openclaw/plugin-sdk/goo import { assertOkOrThrowHttpError, describeImageWithModel, + describeImagesWithModel, normalizeBaseUrl, postJsonRequest, type AudioTranscriptionRequest, @@ -142,6 +143,7 @@ export const googleMediaUnderstandingProvider: MediaUnderstandingProvider = { id: "google", capabilities: ["image", "audio", "video"], describeImage: describeImageWithModel, + describeImages: describeImagesWithModel, transcribeAudio: transcribeGeminiAudio, describeVideo: describeGeminiVideo, }; diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 30894be556d..1ebf7382d52 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -13,6 +13,10 @@ import { listProfilesForProvider, } from "openclaw/plugin-sdk/provider-auth"; import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage"; +import { + minimaxMediaUnderstandingProvider, + minimaxPortalMediaUnderstandingProvider, +} from "./media-understanding-provider.js"; import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; import { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js"; import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js"; @@ -273,6 +277,8 @@ const minimaxPlugin = { ], isModernModelRef: ({ modelId }) => isModernMiniMaxModel(modelId), }); + api.registerMediaUnderstandingProvider(minimaxMediaUnderstandingProvider); + api.registerMediaUnderstandingProvider(minimaxPortalMediaUnderstandingProvider); }, }; diff --git a/extensions/minimax/media-understanding-provider.ts b/extensions/minimax/media-understanding-provider.ts index 2bda4f4d193..4501a96dee9 100644 --- a/extensions/minimax/media-understanding-provider.ts +++ b/extensions/minimax/media-understanding-provider.ts @@ -1,5 +1,6 @@ import { describeImageWithModel, + describeImagesWithModel, type MediaUnderstandingProvider, } from "openclaw/plugin-sdk/media-understanding"; @@ -7,10 +8,12 @@ export const minimaxMediaUnderstandingProvider: MediaUnderstandingProvider = { id: "minimax", capabilities: ["image"], describeImage: describeImageWithModel, + describeImages: describeImagesWithModel, }; export const minimaxPortalMediaUnderstandingProvider: MediaUnderstandingProvider = { id: "minimax-portal", capabilities: ["image"], describeImage: describeImageWithModel, + describeImages: describeImagesWithModel, }; diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index 72b3b6a60ac..5a15c50a857 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -1,5 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { mistralMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "mistral"; @@ -50,6 +51,7 @@ const mistralPlugin = { ], }, }); + api.registerMediaUnderstandingProvider(mistralMediaUnderstandingProvider); }, }; diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index e8d7ecedb0c..80bd7af6763 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -9,6 +9,7 @@ import { getScopedCredentialValue, setScopedCredentialValue, } from "openclaw/plugin-sdk/provider-web-search"; +import { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyMoonshotConfig, applyMoonshotConfigCn, @@ -98,6 +99,7 @@ const moonshotPlugin = { return createMoonshotThinkingWrapper(ctx.streamFn, thinkingType); }, }); + api.registerMediaUnderstandingProvider(moonshotMediaUnderstandingProvider); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ id: "kimi", diff --git a/extensions/moonshot/media-understanding-provider.ts b/extensions/moonshot/media-understanding-provider.ts index 5814ee96e22..6c652ae58d3 100644 --- a/extensions/moonshot/media-understanding-provider.ts +++ b/extensions/moonshot/media-understanding-provider.ts @@ -1,11 +1,12 @@ import { - assertOkOrThrowHttpError, describeImageWithModel, - normalizeBaseUrl, - postJsonRequest, + describeImagesWithModel, type MediaUnderstandingProvider, type VideoDescriptionRequest, type VideoDescriptionResult, + assertOkOrThrowHttpError, + normalizeBaseUrl, + postJsonRequest, } from "openclaw/plugin-sdk/media-understanding"; export const DEFAULT_MOONSHOT_VIDEO_BASE_URL = "https://api.moonshot.ai/v1"; @@ -116,5 +117,6 @@ export const moonshotMediaUnderstandingProvider: MediaUnderstandingProvider = { id: "moonshot", capabilities: ["image", "video"], describeImage: describeImageWithModel, + describeImages: describeImagesWithModel, describeVideo: describeMoonshotVideo, }; diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index 831e49acdd8..d22b7275691 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -1,5 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { buildOpenAISpeechProvider } from "openclaw/plugin-sdk/speech"; +import { openaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; import { buildOpenAIProvider } from "./openai-provider.js"; @@ -12,6 +13,7 @@ const openAIPlugin = { api.registerProvider(buildOpenAIProvider()); api.registerProvider(buildOpenAICodexProviderPlugin()); api.registerSpeechProvider(buildOpenAISpeechProvider()); + api.registerMediaUnderstandingProvider(openaiMediaUnderstandingProvider); }, }; diff --git a/extensions/openai/media-understanding-provider.ts b/extensions/openai/media-understanding-provider.ts index dcb0a731a91..9fb66df20dc 100644 --- a/extensions/openai/media-understanding-provider.ts +++ b/extensions/openai/media-understanding-provider.ts @@ -1,5 +1,6 @@ import { describeImageWithModel, + describeImagesWithModel, transcribeOpenAiCompatibleAudio, type AudioTranscriptionRequest, type MediaUnderstandingProvider, @@ -20,5 +21,6 @@ export const openaiMediaUnderstandingProvider: MediaUnderstandingProvider = { id: "openai", capabilities: ["image", "audio"], describeImage: describeImageWithModel, + describeImages: describeImagesWithModel, transcribeAudio: transcribeOpenAiAudio, }; diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index 0faef49c4fb..109bf5144a1 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -25,6 +25,7 @@ import { DEFAULT_CONTEXT_TOKENS, normalizeModelCompat } from "openclaw/plugin-sd import { createZaiToolStreamWrapper } from "openclaw/plugin-sdk/provider-stream"; import { fetchZaiUsage } from "openclaw/plugin-sdk/provider-usage"; import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js"; +import { zaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyZaiConfig, applyZaiProviderConfig, ZAI_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "zai"; @@ -333,6 +334,7 @@ const zaiPlugin = { fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), isCacheTtlEligible: () => true, }); + api.registerMediaUnderstandingProvider(zaiMediaUnderstandingProvider); }, }; diff --git a/extensions/zai/media-understanding-provider.ts b/extensions/zai/media-understanding-provider.ts index 08f8c186d4d..bd571230b2d 100644 --- a/extensions/zai/media-understanding-provider.ts +++ b/extensions/zai/media-understanding-provider.ts @@ -1,5 +1,6 @@ import { describeImageWithModel, + describeImagesWithModel, type MediaUnderstandingProvider, } from "openclaw/plugin-sdk/media-understanding"; @@ -7,4 +8,5 @@ export const zaiMediaUnderstandingProvider: MediaUnderstandingProvider = { id: "zai", capabilities: ["image"], describeImage: describeImageWithModel, + describeImages: describeImagesWithModel, }; diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index bcec7f32de7..c58a7f9aa1a 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -32,6 +32,7 @@ async function withTempAgentDir(run: (agentDir: string) => Promise): Promi const ONE_PIXEL_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; const ONE_PIXEL_GIF_B64 = "R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs="; +const ONE_PIXEL_JPEG_B64 = "QUJDRA=="; async function withTempWorkspacePng( cb: (args: { workspaceDir: string; imagePath: string }) => Promise, @@ -736,10 +737,10 @@ describe("image tool MiniMax VLM routing", () => { const res = await tool.execute("t1", { prompt: "Compare these images.", - images: [`data:image/png;base64,${pngB64}`, `data:image/gif;base64,${ONE_PIXEL_GIF_B64}`], + images: [`data:image/png;base64,${pngB64}`, `data:image/jpeg;base64,${ONE_PIXEL_JPEG_B64}`], }); - expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(2); const details = res.details as | { images?: Array<{ image: string }>; @@ -756,12 +757,12 @@ describe("image tool MiniMax VLM routing", () => { image: `data:image/png;base64,${pngB64}`, images: [ `data:image/png;base64,${pngB64}`, - `data:image/gif;base64,${ONE_PIXEL_GIF_B64}`, - `data:image/gif;base64,${ONE_PIXEL_GIF_B64}`, + `data:image/jpeg;base64,${ONE_PIXEL_JPEG_B64}`, + `data:image/jpeg;base64,${ONE_PIXEL_JPEG_B64}`, ], }); - expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(2); const dedupedDetails = deduped.details as | { images?: Array<{ image: string }>; @@ -776,7 +777,7 @@ describe("image tool MiniMax VLM routing", () => { maxImages: 1, }); - expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(2); expect(tooMany.details).toMatchObject({ error: "too_many_images", count: 2, diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 402ee0b3eda..8dd471b8a7d 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -1,9 +1,10 @@ -import { type Context, complete } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; +import { getMediaUnderstandingProvider } from "../../media-understanding/providers/index.js"; +import { buildProviderRegistry } from "../../media-understanding/runner.js"; import { loadWebMedia } from "../../plugin-sdk/web-media.js"; import { resolveUserPath } from "../../utils.js"; -import { isMinimaxVlmModel, isMinimaxVlmProvider, minimaxUnderstandImage } from "../minimax-vlm.js"; +import { isMinimaxVlmProvider } from "../minimax-vlm.js"; import { coerceImageAssistantText, coerceImageModelConfig, @@ -14,17 +15,12 @@ import { import { applyImageModelConfigDefaults, buildTextToolResult, - resolveModelFromRegistry, resolveMediaToolLocalRoots, - resolveModelRuntimeApiKey, resolvePromptAndModelOverride, } from "./media-tool-shared.js"; import { hasAuthForProvider, resolveDefaultModelRef } from "./model-config.helpers.js"; import { createSandboxBridgeReadFile, - discoverAuthStorage, - discoverModels, - ensureOpenClawModelsJson, resolveSandboxedBridgeMediaPath, runWithImageModelFallback, type AnyAgentTool, @@ -168,27 +164,6 @@ function pickMaxBytes(cfg?: OpenClawConfig, maxBytesMb?: number): number | undef return undefined; } -function buildImageContext( - prompt: string, - images: Array<{ base64: string; mimeType: string }>, -): Context { - const content: Array< - { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } - > = [{ type: "text", text: prompt }]; - for (const img of images) { - content.push({ type: "image", data: img.base64, mimeType: img.mimeType }); - } - return { - messages: [ - { - role: "user", - content, - timestamp: Date.now(), - }, - ], - }; -} - type ImageSandboxConfig = { root: string; bridge: SandboxFsBridge; @@ -200,7 +175,7 @@ async function runImagePrompt(params: { imageModelConfig: ImageModelConfig; modelOverride?: string; prompt: string; - images: Array<{ base64: string; mimeType: string }>; + images: Array<{ buffer: Buffer; mimeType: string }>; }): Promise<{ text: string; provider: string; @@ -208,50 +183,75 @@ async function runImagePrompt(params: { attempts: Array<{ provider: string; model: string; error: string }>; }> { const effectiveCfg = applyImageModelConfigDefaults(params.cfg, params.imageModelConfig); - - await ensureOpenClawModelsJson(effectiveCfg, params.agentDir); - const authStorage = discoverAuthStorage(params.agentDir); - const modelRegistry = discoverModels(authStorage, params.agentDir); + const providerCfg: OpenClawConfig = effectiveCfg ?? {}; + const providerRegistry = buildProviderRegistry(undefined, providerCfg); const result = await runWithImageModelFallback({ cfg: effectiveCfg, modelOverride: params.modelOverride, run: async (provider, modelId) => { - const model = resolveModelFromRegistry({ modelRegistry, provider, modelId }); - if (!model.input?.includes("image")) { - throw new Error(`Model does not support images: ${provider}/${modelId}`); + const imageProvider = getMediaUnderstandingProvider(provider, providerRegistry); + if (!imageProvider) { + throw new Error(`No media-understanding provider registered for ${provider}`); } - const apiKey = await resolveModelRuntimeApiKey({ - model, - cfg: effectiveCfg, - agentDir: params.agentDir, - authStorage, - }); - - // MiniMax VLM only supports a single image; use the first one. - if (isMinimaxVlmModel(model.provider, model.id)) { - const first = params.images[0]; - const imageDataUrl = `data:${first.mimeType};base64,${first.base64}`; - const text = await minimaxUnderstandImage({ - apiKey, + if (params.images.length > 1 && imageProvider.describeImages) { + const described = await imageProvider.describeImages({ + images: params.images.map((image, index) => ({ + buffer: image.buffer, + fileName: `image-${index + 1}`, + mime: image.mimeType, + })), + provider, + model: modelId, prompt: params.prompt, - imageDataUrl, - modelBaseUrl: model.baseUrl, + maxTokens: resolveImageToolMaxTokens(undefined), + timeoutMs: 30_000, + cfg: providerCfg, + agentDir: params.agentDir, }); - return { text, provider: model.provider, model: model.id }; + return { text: described.text, provider, model: described.model ?? modelId }; + } + if (!imageProvider.describeImage) { + throw new Error(`Provider does not support image analysis: ${provider}`); + } + if (params.images.length === 1) { + const image = params.images[0]; + const described = await imageProvider.describeImage({ + buffer: image.buffer, + fileName: "image-1", + mime: image.mimeType, + provider, + model: modelId, + prompt: params.prompt, + maxTokens: resolveImageToolMaxTokens(undefined), + timeoutMs: 30_000, + cfg: providerCfg, + agentDir: params.agentDir, + }); + return { text: described.text, provider, model: described.model ?? modelId }; } - const context = buildImageContext(params.prompt, params.images); - const message = await complete(model, context, { - apiKey, - maxTokens: resolveImageToolMaxTokens(model.maxTokens), - }); - const text = coerceImageAssistantText({ - message, - provider: model.provider, - model: model.id, - }); - return { text, provider: model.provider, model: model.id }; + const parts: string[] = []; + for (const [index, image] of params.images.entries()) { + const described = await imageProvider.describeImage({ + buffer: image.buffer, + fileName: `image-${index + 1}`, + mime: image.mimeType, + provider, + model: modelId, + prompt: `${params.prompt}\n\nDescribe image ${index + 1} of ${params.images.length}.`, + maxTokens: resolveImageToolMaxTokens(undefined), + timeoutMs: 30_000, + cfg: providerCfg, + agentDir: params.agentDir, + }); + parts.push(`Image ${index + 1}:\n${described.text.trim()}`); + } + return { + text: parts.join("\n\n").trim(), + provider, + model: modelId, + }; }, }); @@ -383,7 +383,7 @@ export function createImageTool(options?: { // MARK: - Load and resolve each image const loadedImages: Array<{ - base64: string; + buffer: Buffer; mimeType: string; resolvedImage: string; rewrittenFrom?: string; @@ -469,9 +469,8 @@ export function createImageTool(options?: { ("contentType" in media && media.contentType) || ("mimeType" in media && media.mimeType) || "image/png"; - const base64 = media.buffer.toString("base64"); loadedImages.push({ - base64, + buffer: media.buffer, mimeType, resolvedImage, ...(resolvedPathInfo.rewrittenFrom @@ -487,7 +486,7 @@ export function createImageTool(options?: { imageModelConfig, modelOverride, prompt: promptRaw, - images: loadedImages.map((img) => ({ base64: img.base64, mimeType: img.mimeType })), + images: loadedImages.map((img) => ({ buffer: img.buffer, mimeType: img.mimeType })), }); const imageDetails = diff --git a/src/media-understanding/providers/image.test.ts b/src/media-understanding/providers/image.test.ts index 51c8739f43a..d52c6590eef 100644 --- a/src/media-understanding/providers/image.test.ts +++ b/src/media-understanding/providers/image.test.ts @@ -8,9 +8,15 @@ const getApiKeyForModelMock = vi.fn(async () => ({ source: "test", mode: "oauth", })); +const resolveApiKeyForProviderMock = vi.fn(async () => ({ + apiKey: "oauth-test", // pragma: allowlist secret + source: "test", + mode: "oauth", +})); const requireApiKeyMock = vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""); const setRuntimeApiKeyMock = vi.fn(); const discoverModelsMock = vi.fn(); +let imageImportSeq = 0; vi.mock("@mariozechner/pi-ai", async (importOriginal) => { const actual = await importOriginal(); @@ -34,6 +40,7 @@ vi.mock("../../agents/models-config.js", () => ({ vi.mock("../../agents/model-auth.js", () => ({ getApiKeyForModel: getApiKeyForModelMock, + resolveApiKeyForProvider: resolveApiKeyForProviderMock, requireApiKey: requireApiKeyMock, })); @@ -44,6 +51,11 @@ vi.mock("../../agents/pi-model-discovery-runtime.js", () => ({ discoverModels: discoverModelsMock, })); +async function importImageModule() { + imageImportSeq += 1; + return await import(/* @vite-ignore */ `./image.js?case=${imageImportSeq}`); +} + describe("describeImageWithModel", () => { beforeEach(() => { vi.clearAllMocks(); @@ -59,7 +71,7 @@ describe("describeImageWithModel", () => { }); it("routes minimax-portal image models through the MiniMax VLM endpoint", async () => { - const { describeImageWithModel } = await import("./image.js"); + const { describeImageWithModel } = await importImageModule(); const result = await describeImageWithModel({ cfg: {}, @@ -109,7 +121,7 @@ describe("describeImageWithModel", () => { content: [{ type: "text", text: "generic ok" }], }); - const { describeImageWithModel } = await import("./image.js"); + const { describeImageWithModel } = await importImageModule(); const result = await describeImageWithModel({ cfg: {}, @@ -153,7 +165,7 @@ describe("describeImageWithModel", () => { content: [{ type: "text", text: "flash ok" }], }); - const { describeImageWithModel } = await import("./image.js"); + const { describeImageWithModel } = await importImageModule(); const result = await describeImageWithModel({ cfg: {}, @@ -203,7 +215,7 @@ describe("describeImageWithModel", () => { content: [{ type: "text", text: "flash lite ok" }], }); - const { describeImageWithModel } = await import("./image.js"); + const { describeImageWithModel } = await importImageModule(); const result = await describeImageWithModel({ cfg: {}, diff --git a/src/media-understanding/providers/image.ts b/src/media-understanding/providers/image.ts index 1511a7c9bb9..9d7dc67949b 100644 --- a/src/media-understanding/providers/image.ts +++ b/src/media-understanding/providers/image.ts @@ -1,11 +1,20 @@ import type { Api, Context, Model } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; import { isMinimaxVlmModel, minimaxUnderstandImage } from "../../agents/minimax-vlm.js"; -import { getApiKeyForModel, requireApiKey } from "../../agents/model-auth.js"; +import { + getApiKeyForModel, + requireApiKey, + resolveApiKeyForProvider, +} from "../../agents/model-auth.js"; import { normalizeModelRef } from "../../agents/model-selection.js"; import { ensureOpenClawModelsJson } from "../../agents/models-config.js"; import { coerceImageAssistantText } from "../../agents/tools/image-tool.helpers.js"; -import type { ImageDescriptionRequest, ImageDescriptionResult } from "../types.js"; +import type { + ImageDescriptionRequest, + ImageDescriptionResult, + ImagesDescriptionRequest, + ImagesDescriptionResult, +} from "../types.js"; let piModelDiscoveryRuntimePromise: Promise< typeof import("../../agents/pi-model-discovery-runtime.js") @@ -16,14 +25,29 @@ function loadPiModelDiscoveryRuntime() { return piModelDiscoveryRuntimePromise; } -export async function describeImageWithModel( - params: ImageDescriptionRequest, -): Promise { +function resolveImageToolMaxTokens(modelMaxTokens: number | undefined, requestedMaxTokens = 4096) { + if ( + typeof modelMaxTokens !== "number" || + !Number.isFinite(modelMaxTokens) || + modelMaxTokens <= 0 + ) { + return requestedMaxTokens; + } + return Math.min(requestedMaxTokens, modelMaxTokens); +} + +async function resolveImageRuntime(params: { + cfg: ImageDescriptionRequest["cfg"]; + agentDir: string; + provider: string; + model: string; + profile?: string; + preferredProfile?: string; +}): Promise<{ apiKey: string; model: Model }> { await ensureOpenClawModelsJson(params.cfg, params.agentDir); const { discoverAuthStorage, discoverModels } = await loadPiModelDiscoveryRuntime(); const authStorage = discoverAuthStorage(params.agentDir); const modelRegistry = discoverModels(authStorage, params.agentDir); - // Keep direct media config entries compatible with deprecated provider model aliases. const resolvedRef = normalizeModelRef(params.provider, params.model); const model = modelRegistry.find(resolvedRef.provider, resolvedRef.model) as Model | null; if (!model) { @@ -41,33 +65,132 @@ export async function describeImageWithModel( }); const apiKey = requireApiKey(apiKeyInfo, model.provider); authStorage.setRuntimeApiKey(model.provider, apiKey); + return { apiKey, model }; +} - const base64 = params.buffer.toString("base64"); - if (isMinimaxVlmModel(model.provider, model.id)) { - const text = await minimaxUnderstandImage({ - apiKey, - prompt: params.prompt ?? "Describe the image.", - imageDataUrl: `data:${params.mime ?? "image/jpeg"};base64,${base64}`, - modelBaseUrl: model.baseUrl, - }); - return { text, model: model.id }; - } - - const context: Context = { +function buildImageContext( + prompt: string, + images: Array<{ buffer: Buffer; mime?: string }>, +): Context { + return { messages: [ { role: "user", content: [ - { type: "text", text: params.prompt ?? "Describe the image." }, - { type: "image", data: base64, mimeType: params.mime ?? "image/jpeg" }, + { type: "text", text: prompt }, + ...images.map((image) => ({ + type: "image" as const, + data: image.buffer.toString("base64"), + mimeType: image.mime ?? "image/jpeg", + })), ], timestamp: Date.now(), }, ], }; +} + +async function describeImagesWithMinimax(params: { + apiKey: string; + modelId: string; + modelBaseUrl?: string; + prompt: string; + images: Array<{ buffer: Buffer; mime?: string }>; +}): Promise { + const responses: string[] = []; + for (const [index, image] of params.images.entries()) { + const prompt = + params.images.length > 1 + ? `${params.prompt}\n\nDescribe image ${index + 1} of ${params.images.length} independently.` + : params.prompt; + const text = await minimaxUnderstandImage({ + apiKey: params.apiKey, + prompt, + imageDataUrl: `data:${image.mime ?? "image/jpeg"};base64,${image.buffer.toString("base64")}`, + modelBaseUrl: params.modelBaseUrl, + }); + responses.push(params.images.length > 1 ? `Image ${index + 1}:\n${text.trim()}` : text.trim()); + } + return { + text: responses.join("\n\n").trim(), + model: params.modelId, + }; +} + +function isUnknownModelError(err: unknown): boolean { + return err instanceof Error && /^Unknown model:/i.test(err.message); +} + +function resolveConfiguredProviderBaseUrl( + cfg: ImageDescriptionRequest["cfg"], + provider: string, +): string | undefined { + const direct = cfg.models?.providers?.[provider]; + if (typeof direct?.baseUrl === "string" && direct.baseUrl.trim()) { + return direct.baseUrl.trim(); + } + return undefined; +} + +async function resolveMinimaxVlmFallbackRuntime(params: { + cfg: ImageDescriptionRequest["cfg"]; + agentDir: string; + provider: string; + profile?: string; + preferredProfile?: string; +}): Promise<{ apiKey: string; modelBaseUrl?: string }> { + const auth = await resolveApiKeyForProvider({ + provider: params.provider, + cfg: params.cfg, + profileId: params.profile, + preferredProfile: params.preferredProfile, + agentDir: params.agentDir, + }); + return { + apiKey: requireApiKey(auth, params.provider), + modelBaseUrl: resolveConfiguredProviderBaseUrl(params.cfg, params.provider), + }; +} + +export async function describeImagesWithModel( + params: ImagesDescriptionRequest, +): Promise { + const prompt = params.prompt ?? "Describe the image."; + let apiKey: string; + let model: Model | undefined; + + try { + const resolved = await resolveImageRuntime(params); + apiKey = resolved.apiKey; + model = resolved.model; + } catch (err) { + if (!isMinimaxVlmModel(params.provider, params.model) || !isUnknownModelError(err)) { + throw err; + } + const fallback = await resolveMinimaxVlmFallbackRuntime(params); + return await describeImagesWithMinimax({ + apiKey: fallback.apiKey, + modelId: params.model, + modelBaseUrl: fallback.modelBaseUrl, + prompt, + images: params.images, + }); + } + + if (isMinimaxVlmModel(model.provider, model.id)) { + return await describeImagesWithMinimax({ + apiKey, + modelId: model.id, + modelBaseUrl: model.baseUrl, + prompt, + images: params.images, + }); + } + + const context = buildImageContext(prompt, params.images); const message = await complete(model, context, { apiKey, - maxTokens: params.maxTokens ?? 512, + maxTokens: resolveImageToolMaxTokens(model.maxTokens, params.maxTokens ?? 512), }); const text = coerceImageAssistantText({ message, @@ -76,3 +199,26 @@ export async function describeImageWithModel( }); return { text, model: model.id }; } + +export async function describeImageWithModel( + params: ImageDescriptionRequest, +): Promise { + return await describeImagesWithModel({ + images: [ + { + buffer: params.buffer, + fileName: params.fileName, + mime: params.mime, + }, + ], + model: params.model, + provider: params.provider, + prompt: params.prompt, + maxTokens: params.maxTokens, + timeoutMs: params.timeoutMs, + profile: params.profile, + preferredProfile: params.preferredProfile, + agentDir: params.agentDir, + cfg: params.cfg, + }); +} diff --git a/src/media-understanding/providers/index.ts b/src/media-understanding/providers/index.ts index 67a45fc2019..32d1d6bcf9a 100644 --- a/src/media-understanding/providers/index.ts +++ b/src/media-understanding/providers/index.ts @@ -1,10 +1,33 @@ +import { anthropicMediaUnderstandingProvider } from "../../../extensions/anthropic/media-understanding-provider.js"; +import { googleMediaUnderstandingProvider } from "../../../extensions/google/media-understanding-provider.js"; +import { + minimaxMediaUnderstandingProvider, + minimaxPortalMediaUnderstandingProvider, +} from "../../../extensions/minimax/media-understanding-provider.js"; +import { mistralMediaUnderstandingProvider } from "../../../extensions/mistral/media-understanding-provider.js"; +import { moonshotMediaUnderstandingProvider } from "../../../extensions/moonshot/media-understanding-provider.js"; +import { openaiMediaUnderstandingProvider } from "../../../extensions/openai/media-understanding-provider.js"; +import { zaiMediaUnderstandingProvider } from "../../../extensions/zai/media-understanding-provider.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { loadOpenClawPlugins } from "../../plugins/loader.js"; import { getActivePluginRegistry } from "../../plugins/runtime.js"; import type { MediaUnderstandingProvider } from "../types.js"; import { deepgramProvider } from "./deepgram/index.js"; import { groqProvider } from "./groq/index.js"; -const PROVIDERS: MediaUnderstandingProvider[] = [groqProvider, deepgramProvider]; +const PROVIDERS: MediaUnderstandingProvider[] = [ + groqProvider, + deepgramProvider, + anthropicMediaUnderstandingProvider, + googleMediaUnderstandingProvider, + minimaxMediaUnderstandingProvider, + minimaxPortalMediaUnderstandingProvider, + mistralMediaUnderstandingProvider, + moonshotMediaUnderstandingProvider, + openaiMediaUnderstandingProvider, + zaiMediaUnderstandingProvider, +]; function mergeProviderIntoRegistry( registry: Map, @@ -32,12 +55,18 @@ export function normalizeMediaProviderId(id: string): string { export function buildMediaUnderstandingRegistry( overrides?: Record, + cfg?: OpenClawConfig, ): Map { const registry = new Map(); for (const provider of PROVIDERS) { mergeProviderIntoRegistry(registry, provider); } - for (const entry of getActivePluginRegistry()?.mediaUnderstandingProviders ?? []) { + const active = getActivePluginRegistry(); + const pluginRegistry = + (active?.mediaUnderstandingProviders?.length ?? 0) > 0 || !cfg + ? active + : loadOpenClawPlugins({ config: cfg }); + for (const entry of pluginRegistry?.mediaUnderstandingProviders ?? []) { mergeProviderIntoRegistry(registry, entry.provider); } if (overrides) { diff --git a/src/media-understanding/runner.ts b/src/media-understanding/runner.ts index a04cc6420fa..807edb45c22 100644 --- a/src/media-understanding/runner.ts +++ b/src/media-understanding/runner.ts @@ -75,8 +75,9 @@ export type RunCapabilityResult = { export function buildProviderRegistry( overrides?: Record, + cfg?: OpenClawConfig, ): ProviderRegistry { - return buildMediaUnderstandingRegistry(overrides); + return buildMediaUnderstandingRegistry(overrides, cfg); } export function normalizeMediaAttachments(ctx: MsgContext): MediaAttachment[] { diff --git a/src/media-understanding/runtime.ts b/src/media-understanding/runtime.ts index e9351921dac..043baf81f91 100644 --- a/src/media-understanding/runtime.ts +++ b/src/media-understanding/runtime.ts @@ -48,7 +48,7 @@ export async function runMediaUnderstandingFile( return { text: undefined }; } - const providerRegistry = buildProviderRegistry(); + const providerRegistry = buildProviderRegistry(undefined, params.cfg); const cache = createMediaAttachmentCache(attachments, { localPathRoots: [path.dirname(params.filePath)], }); diff --git a/src/media-understanding/types.ts b/src/media-understanding/types.ts index 60c425626de..36c467e105f 100644 --- a/src/media-understanding/types.ts +++ b/src/media-understanding/types.ts @@ -90,6 +90,25 @@ export type ImageDescriptionRequest = { buffer: Buffer; fileName: string; mime?: string; + prompt?: string; + maxTokens?: number; + timeoutMs: number; + profile?: string; + preferredProfile?: string; + agentDir: string; + cfg: import("../config/config.js").OpenClawConfig; + model: string; + provider: string; +}; + +export type ImagesDescriptionInput = { + buffer: Buffer; + fileName: string; + mime?: string; +}; + +export type ImagesDescriptionRequest = { + images: ImagesDescriptionInput[]; model: string; provider: string; prompt?: string; @@ -106,10 +125,16 @@ export type ImageDescriptionResult = { model?: string; }; +export type ImagesDescriptionResult = { + text: string; + model?: string; +}; + export type MediaUnderstandingProvider = { id: string; capabilities?: MediaUnderstandingCapability[]; transcribeAudio?: (req: AudioTranscriptionRequest) => Promise; describeVideo?: (req: VideoDescriptionRequest) => Promise; describeImage?: (req: ImageDescriptionRequest) => Promise; + describeImages?: (req: ImagesDescriptionRequest) => Promise; }; diff --git a/src/plugin-sdk/media-understanding.ts b/src/plugin-sdk/media-understanding.ts index 052736afc3d..0d14685dbdf 100644 --- a/src/plugin-sdk/media-understanding.ts +++ b/src/plugin-sdk/media-understanding.ts @@ -5,12 +5,15 @@ export type { AudioTranscriptionResult, ImageDescriptionRequest, ImageDescriptionResult, + ImagesDescriptionInput, + ImagesDescriptionRequest, + ImagesDescriptionResult, MediaUnderstandingProvider, VideoDescriptionRequest, VideoDescriptionResult, } from "../media-understanding/types.js"; -export { describeImageWithModel } from "../media-understanding/providers/image.js"; +export { describeImageWithModel, describeImagesWithModel } from "../media-understanding/providers/image.js"; export { transcribeOpenAiCompatibleAudio } from "../media-understanding/providers/openai-compatible-audio.js"; export { assertOkOrThrowHttpError, diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 06430449808..0f6d588ea1a 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -43,6 +43,16 @@ function findMediaUnderstandingProviderIdsForPlugin(pluginId: string) { .toSorted((left, right) => left.localeCompare(right)); } +function findMediaUnderstandingProviderForPlugin(pluginId: string) { + const entry = mediaUnderstandingProviderContractRegistry.find( + (candidate) => candidate.pluginId === pluginId, + ); + if (!entry) { + throw new Error(`media-understanding provider contract missing for ${pluginId}`); + } + return entry.provider; +} + function findRegistrationForPlugin(pluginId: string) { const entry = pluginRegistrationContractRegistry.find( (candidate) => candidate.pluginId === pluginId, @@ -141,4 +151,25 @@ describe("plugin contract registry", () => { expect(findSpeechProviderForPlugin("elevenlabs").listVoices).toEqual(expect.any(Function)); expect(findSpeechProviderForPlugin("microsoft").listVoices).toEqual(expect.any(Function)); }); + + it("keeps bundled multi-image support explicit", () => { + expect(findMediaUnderstandingProviderForPlugin("anthropic").describeImages).toEqual( + expect.any(Function), + ); + expect(findMediaUnderstandingProviderForPlugin("google").describeImages).toEqual( + expect.any(Function), + ); + expect(findMediaUnderstandingProviderForPlugin("minimax").describeImages).toEqual( + expect.any(Function), + ); + expect(findMediaUnderstandingProviderForPlugin("moonshot").describeImages).toEqual( + expect.any(Function), + ); + expect(findMediaUnderstandingProviderForPlugin("openai").describeImages).toEqual( + expect.any(Function), + ); + expect(findMediaUnderstandingProviderForPlugin("zai").describeImages).toEqual( + expect.any(Function), + ); + }); }); From da34f81ce23744c2a69e37ab585e2cc95b6adf9b Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:01:34 -0500 Subject: [PATCH 103/128] fix(secrets): scope message SecretRef resolution and harden doctor/status paths (#48728) * fix(secrets): scope message runtime resolution and harden doctor/status * docs: align message/doctor/status SecretRef behavior notes * test(cli): accept scoped targetIds wiring in secret-resolution coverage * fix(secrets): keep scoped allowedPaths isolation and tighten coverage gate * fix(secrets): avoid default-account coercion in scoped target selection * test(doctor): cover inactive telegram secretref inspect path * docs Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> * changelog Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --------- Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/channels/discord.md | 2 +- docs/cli/doctor.md | 2 + docs/cli/message.md | 10 +++ docs/cli/status.md | 1 + src/agents/tools/discord-actions-messaging.ts | 74 +++++++++------- src/agents/tools/discord-actions.test.ts | 53 +++++++++-- src/agents/tools/message-tool.test.ts | 71 ++++++++++++++- src/agents/tools/message-tool.ts | 39 +++++--- src/channels/plugins/message-actions.test.ts | 41 ++++++++- src/channels/plugins/message-actions.ts | 88 +++++++++++++++++-- src/cli/command-secret-gateway.test.ts | 39 ++++++++ src/cli/command-secret-gateway.ts | 8 ++ ...command-secret-resolution.coverage.test.ts | 9 +- src/cli/command-secret-targets.test.ts | 80 +++++++++++++++++ src/cli/command-secret-targets.ts | 66 +++++++++++++- src/cli/message-secret-scope.test.ts | 56 ++++++++++++ src/cli/message-secret-scope.ts | 83 +++++++++++++++++ src/commands/doctor-config-flow.test.ts | 55 ++++++++++++ src/commands/doctor-config-flow.ts | 17 +++- src/commands/message.test.ts | 7 ++ src/commands/message.ts | 17 +++- src/commands/status-all.ts | 21 +++-- src/commands/status-all/diagnosis.ts | 12 +++ src/commands/status-all/report-lines.test.ts | 6 ++ src/infra/outbound/channel-selection.test.ts | 20 +++++ src/infra/outbound/channel-selection.ts | 52 ++++++++++- 27 files changed, 854 insertions(+), 76 deletions(-) create mode 100644 src/cli/message-secret-scope.test.ts create mode 100644 src/cli/message-secret-scope.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff37ae11c0..042332d3844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - 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. - Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import. +- Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant. ### Breaking diff --git a/docs/channels/discord.md b/docs/channels/discord.md index e179417e9b8..2b2266c4c83 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -168,7 +168,7 @@ openclaw pairing approve discord Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account. -For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. Account policy/retry settings still come from the selected account in the active runtime snapshot. +For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. This applies to send and read/probe-style actions (for example read/search/fetch/thread/pins/permissions). Account policy/retry settings still come from the selected account in the active runtime snapshot. ## Recommended: Set up a guild workspace diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 4718135ee68..d5429b5b01c 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -32,6 +32,8 @@ Notes: - Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing. - If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`). - If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials. +- If channel SecretRef inspection fails in a fix path, doctor continues and reports a warning instead of exiting early. +- Telegram `allowFrom` username auto-resolution (`doctor --fix`) requires a resolvable Telegram token in the current command path. If token inspection is unavailable, doctor reports a warning and skips auto-resolution for that pass. ## macOS: `launchctl` env overrides diff --git a/docs/cli/message.md b/docs/cli/message.md index 1633554f316..665d0e74bd2 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -50,6 +50,16 @@ Name lookup: - `--dry-run` - `--verbose` +## SecretRef behavior + +- `openclaw message` resolves supported channel SecretRefs before running the selected action. +- Resolution is scoped to the active action target when possible: + - channel-scoped when `--channel` is set (or inferred from prefixed targets like `discord:...`) + - account-scoped when `--account` is set (channel globals + selected account surfaces) + - when `--account` is omitted, OpenClaw does not force a `default` account SecretRef scope +- Unresolved SecretRefs on unrelated channels do not block a targeted message action. +- If the selected channel/account SecretRef is unresolved, the command fails closed for that action. + ## Actions ### Core diff --git a/docs/cli/status.md b/docs/cli/status.md index 770bf6ab50d..3f0f5bb5bf8 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -27,3 +27,4 @@ Notes: - Read-only status surfaces (`status`, `status --json`, `status --all`) resolve supported SecretRefs for their targeted config paths when possible. - If a supported channel SecretRef is configured but unavailable in the current command path, status stays read-only and reports degraded output instead of crashing. Human output shows warnings such as “configured token unavailable in this command path”, and JSON output includes `secretDiagnostics`. - When command-local SecretRef resolution succeeds, status prefers the resolved snapshot and clears transient “secret unavailable” channel markers from the final output. +- `status --all` includes a Secrets overview row and a diagnosis section that summarizes secret diagnostics (truncated for readability) without stopping report generation. diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 20fdfcc6a02..bad969ede80 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -182,8 +182,8 @@ export async function handleDiscordMessagingAction( } const channelId = resolveChannelId(); const permissions = accountId - ? await fetchChannelPermissionsDiscord(channelId, { accountId }) - : await fetchChannelPermissionsDiscord(channelId); + ? await fetchChannelPermissionsDiscord(channelId, { ...cfgOptions, accountId }) + : await fetchChannelPermissionsDiscord(channelId, cfgOptions); return jsonResult({ ok: true, permissions }); } case "fetchMessage": { @@ -206,8 +206,8 @@ export async function handleDiscordMessagingAction( ); } const message = accountId - ? await fetchMessageDiscord(channelId, messageId, { accountId }) - : await fetchMessageDiscord(channelId, messageId); + ? await fetchMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }) + : await fetchMessageDiscord(channelId, messageId, cfgOptions); return jsonResult({ ok: true, message: normalizeMessage(message), @@ -228,8 +228,8 @@ export async function handleDiscordMessagingAction( around: readStringParam(params, "around"), }; const messages = accountId - ? await readMessagesDiscord(channelId, query, { accountId }) - : await readMessagesDiscord(channelId, query); + ? await readMessagesDiscord(channelId, query, { ...cfgOptions, accountId }) + : await readMessagesDiscord(channelId, query, cfgOptions); return jsonResult({ ok: true, messages: messages.map((message) => normalizeMessage(message)), @@ -338,8 +338,8 @@ export async function handleDiscordMessagingAction( required: true, }); const message = accountId - ? await editMessageDiscord(channelId, messageId, { content }, { accountId }) - : await editMessageDiscord(channelId, messageId, { content }); + ? await editMessageDiscord(channelId, messageId, { content }, { ...cfgOptions, accountId }) + : await editMessageDiscord(channelId, messageId, { content }, cfgOptions); return jsonResult({ ok: true, message }); } case "deleteMessage": { @@ -351,9 +351,9 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await deleteMessageDiscord(channelId, messageId, { accountId }); + await deleteMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); } else { - await deleteMessageDiscord(channelId, messageId); + await deleteMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -375,8 +375,8 @@ export async function handleDiscordMessagingAction( appliedTags: appliedTags ?? undefined, }; const thread = accountId - ? await createThreadDiscord(channelId, payload, { accountId }) - : await createThreadDiscord(channelId, payload); + ? await createThreadDiscord(channelId, payload, { ...cfgOptions, accountId }) + : await createThreadDiscord(channelId, payload, cfgOptions); return jsonResult({ ok: true, thread }); } case "threadList": { @@ -399,15 +399,18 @@ export async function handleDiscordMessagingAction( before, limit, }, - { accountId }, + { ...cfgOptions, accountId }, ) - : await listThreadsDiscord({ - guildId, - channelId, - includeArchived, - before, - limit, - }); + : await listThreadsDiscord( + { + guildId, + channelId, + includeArchived, + before, + limit, + }, + cfgOptions, + ); return jsonResult({ ok: true, threads }); } case "threadReply": { @@ -438,9 +441,9 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await pinMessageDiscord(channelId, messageId, { accountId }); + await pinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); } else { - await pinMessageDiscord(channelId, messageId); + await pinMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -453,9 +456,9 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await unpinMessageDiscord(channelId, messageId, { accountId }); + await unpinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); } else { - await unpinMessageDiscord(channelId, messageId); + await unpinMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -465,8 +468,8 @@ export async function handleDiscordMessagingAction( } const channelId = resolveChannelId(); const pins = accountId - ? await listPinsDiscord(channelId, { accountId }) - : await listPinsDiscord(channelId); + ? await listPinsDiscord(channelId, { ...cfgOptions, accountId }) + : await listPinsDiscord(channelId, cfgOptions); return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) }); } case "searchMessages": { @@ -495,15 +498,18 @@ export async function handleDiscordMessagingAction( authorIds: authorIdList.length ? authorIdList : undefined, limit, }, - { accountId }, + { ...cfgOptions, accountId }, ) - : await searchMessagesDiscord({ - guildId, - content, - channelIds: channelIdList.length ? channelIdList : undefined, - authorIds: authorIdList.length ? authorIdList : undefined, - limit, - }); + : await searchMessagesDiscord( + { + guildId, + content, + channelIds: channelIdList.length ? channelIdList : undefined, + authorIds: authorIdList.length ? authorIdList : undefined, + limit, + }, + cfgOptions, + ); if (!results || typeof results !== "object") { return jsonResult({ ok: true, results }); } diff --git a/src/agents/tools/discord-actions.test.ts b/src/agents/tools/discord-actions.test.ts index ab2d71caf23..c03cb2fdafa 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/src/agents/tools/discord-actions.test.ts @@ -211,6 +211,24 @@ describe("handleDiscordMessagingAction", () => { expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString()); }); + it("threads provided cfg into readMessages calls", async () => { + const cfg = { + channels: { + discord: { + token: "token", + }, + }, + } as OpenClawConfig; + await handleDiscordMessagingAction( + "readMessages", + { channelId: "C1" }, + enableAllActions, + {}, + cfg, + ); + expect(readMessagesDiscord).toHaveBeenCalledWith("C1", expect.any(Object), { cfg }); + }); + it("adds normalized timestamps to fetchMessage payloads", async () => { fetchMessageDiscord.mockResolvedValueOnce({ id: "1", @@ -229,6 +247,24 @@ describe("handleDiscordMessagingAction", () => { expect(payload.message?.timestampUtc).toBe(new Date(expectedMs).toISOString()); }); + it("threads provided cfg into fetchMessage calls", async () => { + const cfg = { + channels: { + discord: { + token: "token", + }, + }, + } as OpenClawConfig; + await handleDiscordMessagingAction( + "fetchMessage", + { guildId: "G1", channelId: "C1", messageId: "M1" }, + enableAllActions, + {}, + cfg, + ); + expect(fetchMessageDiscord).toHaveBeenCalledWith("C1", "M1", { cfg }); + }); + it("adds normalized timestamps to listPins payloads", async () => { listPinsDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" }]); @@ -338,12 +374,17 @@ describe("handleDiscordMessagingAction", () => { }, enableAllActions, ); - expect(createThreadDiscord).toHaveBeenCalledWith("C1", { - name: "Forum thread", - messageId: undefined, - autoArchiveMinutes: undefined, - content: "Initial forum post body", - }); + expect(createThreadDiscord).toHaveBeenCalledWith( + "C1", + { + name: "Forum thread", + messageId: undefined, + autoArchiveMinutes: undefined, + content: "Initial forum post body", + appliedTags: undefined, + }, + {}, + ); }); }); diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index a148494c8de..88062eacaa7 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js"; import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; @@ -8,6 +8,11 @@ import { createMessageTool } from "./message-tool.js"; const mocks = vi.hoisted(() => ({ runMessageAction: vi.fn(), + loadConfig: vi.fn(() => ({})), + resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({ + resolvedConfig: config, + diagnostics: [], + })), })); vi.mock("../../infra/outbound/message-action-runner.js", async () => { @@ -20,6 +25,18 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => { }; }); +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: mocks.loadConfig, + }; +}); + +vi.mock("../../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, +})); + function mockSendResult(overrides: { channel?: string; to?: string } = {}) { mocks.runMessageAction.mockClear(); mocks.runMessageAction.mockResolvedValue({ @@ -41,6 +58,15 @@ function getActionEnum(properties: Record) { return (properties.action as { enum?: string[] } | undefined)?.enum ?? []; } +beforeEach(() => { + mocks.runMessageAction.mockReset(); + mocks.loadConfig.mockReset().mockReturnValue({}); + mocks.resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({ + resolvedConfig: config, + diagnostics: [], + })); +}); + function createChannelPlugin(params: { id: string; label: string; @@ -101,6 +127,49 @@ async function executeSend(params: { | undefined; } +describe("message tool secret scoping", () => { + it("scopes command-time secret resolution to the selected channel/account", async () => { + mockSendResult({ channel: "discord", to: "discord:123" }); + mocks.loadConfig.mockReturnValue({ + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_TOKEN" }, + accounts: { + ops: { token: { source: "env", provider: "default", id: "DISCORD_OPS_TOKEN" } }, + chat: { token: { source: "env", provider: "default", id: "DISCORD_CHAT_TOKEN" } }, + }, + }, + slack: { + botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" }, + }, + }, + }); + + const tool = createMessageTool({ + currentChannelProvider: "discord", + agentAccountId: "ops", + }); + + await tool.execute("1", { + action: "send", + target: "channel:123", + message: "hi", + }); + + const secretResolveCall = mocks.resolveCommandSecretRefsViaGateway.mock.calls[0]?.[0] as { + targetIds?: Set; + allowedPaths?: Set; + }; + expect(secretResolveCall.targetIds).toBeInstanceOf(Set); + expect( + [...(secretResolveCall.targetIds ?? [])].every((id) => id.startsWith("channels.discord.")), + ).toBe(true); + expect(secretResolveCall.allowedPaths).toEqual( + new Set(["channels.discord.token", "channels.discord.accounts.ops.token"]), + ); + }); +}); + describe("message tool agent routing", () => { it("derives agentId from the session key", async () => { mockSendResult(); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 0e6c846e75d..1dcaf04e1f0 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -12,7 +12,8 @@ import { type ChannelMessageActionName, } from "../../channels/plugins/types.js"; import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; -import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; +import { getScopedChannelsCommandSecretTargets } from "../../cli/command-secret-targets.js"; +import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; @@ -820,19 +821,35 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { } } - const cfg = options?.config - ? options.config - : ( - await resolveCommandSecretRefsViaGateway({ - config: loadConfig(), - commandName: "tools.message", - targetIds: getChannelsCommandSecretTargetIds(), - mode: "enforce_resolved", - }) - ).resolvedConfig; const action = readStringParam(params, "action", { required: true, }) as ChannelMessageActionName; + let cfg = options?.config; + if (!cfg) { + const loadedRaw = loadConfig(); + const scope = resolveMessageSecretScope({ + channel: params.channel, + target: params.target, + targets: params.targets, + fallbackChannel: options?.currentChannelProvider, + accountId: params.accountId, + fallbackAccountId: agentAccountId, + }); + const scopedTargets = getScopedChannelsCommandSecretTargets({ + config: loadedRaw, + channel: scope.channel, + accountId: scope.accountId, + }); + cfg = ( + await resolveCommandSecretRefsViaGateway({ + config: loadedRaw, + commandName: "tools.message", + targetIds: scopedTargets.targetIds, + ...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}), + mode: "enforce_resolved", + }) + ).resolvedConfig; + } const requireExplicitTarget = options?.requireExplicitTarget === true; if (requireExplicitTarget && actionNeedsExplicitTarget(action)) { const explicitTarget = diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index 92af406e2f1..17fdf8fe193 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -1,13 +1,16 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { defaultRuntime } from "../../runtime.js"; import { createChannelTestPluginBase, createTestRegistry, } from "../../test-utils/channel-plugins.js"; import { + __testing, channelSupportsMessageCapability, channelSupportsMessageCapabilityForChannel, + listChannelMessageActions, listChannelMessageCapabilities, listChannelMessageCapabilitiesForChannel, } from "./message-actions.js"; @@ -56,8 +59,12 @@ function activateMessageActionTestRegistry() { } describe("message action capability checks", () => { + const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); + afterEach(() => { setActivePluginRegistry(emptyRegistry); + __testing.resetLoggedMessageActionErrors(); + errorSpy.mockClear(); }); it("aggregates capabilities across plugins", () => { @@ -122,4 +129,36 @@ describe("message action capability checks", () => { false, ); }); + + it("skips crashing action/capability discovery paths and logs once", () => { + const crashingPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "discord", + label: "Discord", + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + }, + }), + actions: { + listActions: () => { + throw new Error("boom"); + }, + getCapabilities: () => { + throw new Error("boom"); + }, + }, + }; + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", source: "test", plugin: crashingPlugin }]), + ); + + expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]); + expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]); + expect(errorSpy).toHaveBeenCalledTimes(2); + + expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]); + expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]); + expect(errorSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 506f2204493..07d08171582 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -1,5 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; +import { defaultRuntime } from "../../runtime.js"; import { getChannelPlugin, listChannelPlugins } from "./index.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js"; @@ -16,13 +17,54 @@ function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boole ); } +const loggedMessageActionErrors = new Set(); + +function logMessageActionError(params: { + pluginId: string; + operation: "listActions" | "getCapabilities"; + error: unknown; +}) { + const message = params.error instanceof Error ? params.error.message : String(params.error); + const key = `${params.pluginId}:${params.operation}:${message}`; + if (loggedMessageActionErrors.has(key)) { + return; + } + loggedMessageActionErrors.add(key); + const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null; + defaultRuntime.error?.( + `[message-actions] ${params.pluginId}.actions.${params.operation} failed: ${stack ?? message}`, + ); +} + +function runListActionsSafely(params: { + pluginId: string; + cfg: OpenClawConfig; + listActions: NonNullable; +}): ChannelMessageActionName[] { + try { + const listed = params.listActions({ cfg: params.cfg }); + return Array.isArray(listed) ? listed : []; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "listActions", + error, + }); + return []; + } +} + export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { const actions = new Set(["send", "broadcast"]); for (const plugin of listChannelPlugins()) { - const list = plugin.actions?.listActions?.({ cfg }); - if (!list) { + if (!plugin.actions?.listActions) { continue; } + const list = runListActionsSafely({ + pluginId: plugin.id, + cfg, + listActions: plugin.actions.listActions, + }); for (const action of list) { actions.add(action); } @@ -30,11 +72,21 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc return Array.from(actions); } -function listCapabilities( - actions: ChannelActions, - cfg: OpenClawConfig, -): readonly ChannelMessageCapability[] { - return actions.getCapabilities?.({ cfg }) ?? []; +function listCapabilities(params: { + pluginId: string; + actions: ChannelActions; + cfg: OpenClawConfig; +}): readonly ChannelMessageCapability[] { + try { + return params.actions.getCapabilities?.({ cfg: params.cfg }) ?? []; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "getCapabilities", + error, + }); + return []; + } } export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { @@ -43,7 +95,11 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess if (!plugin.actions) { continue; } - for (const capability of listCapabilities(plugin.actions, cfg)) { + for (const capability of listCapabilities({ + pluginId: plugin.id, + actions: plugin.actions, + cfg, + })) { capabilities.add(capability); } } @@ -58,7 +114,15 @@ export function listChannelMessageCapabilitiesForChannel(params: { return []; } const plugin = getChannelPlugin(params.channel as Parameters[0]); - return plugin?.actions ? Array.from(listCapabilities(plugin.actions, params.cfg)) : []; + return plugin?.actions + ? Array.from( + listCapabilities({ + pluginId: plugin.id, + actions: plugin.actions, + cfg: params.cfg, + }), + ) + : []; } export function channelSupportsMessageCapability( @@ -95,3 +159,9 @@ export async function dispatchChannelMessageAction( } return await plugin.actions.handleAction(ctx); } + +export const __testing = { + resetLoggedMessageActionErrors() { + loggedMessageActionErrors.clear(); + }, +}; diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index c9de91d4257..6a2dff29582 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -155,6 +155,45 @@ describe("resolveCommandSecretRefsViaGateway", () => { expect(result.resolvedConfig.talk?.apiKey).toBe("sk-live"); }); + it("enforces unresolved checks only for allowed paths when provided", async () => { + callGateway.mockResolvedValueOnce({ + assignments: [ + { + path: "channels.discord.accounts.ops.token", + pathSegments: ["channels", "discord", "accounts", "ops", "token"], + value: "ops-token", + }, + ], + diagnostics: [], + }); + + const result = await resolveCommandSecretRefsViaGateway({ + config: { + channels: { + discord: { + accounts: { + ops: { + token: { source: "env", provider: "default", id: "DISCORD_OPS_TOKEN" }, + }, + chat: { + token: { source: "env", provider: "default", id: "DISCORD_CHAT_TOKEN" }, + }, + }, + }, + }, + } as OpenClawConfig, + commandName: "message", + targetIds: new Set(["channels.discord.accounts.*.token"]), + allowedPaths: new Set(["channels.discord.accounts.ops.token"]), + }); + + expect(result.resolvedConfig.channels?.discord?.accounts?.ops?.token).toBe("ops-token"); + expect(result.targetStatesByPath).toEqual({ + "channels.discord.accounts.ops.token": "resolved_gateway", + }); + expect(result.hadUnresolvedTargets).toBe(false); + }); + it("fails fast when gateway-backed resolution is unavailable", async () => { const envKey = "TALK_API_KEY_FAILFAST"; const priorValue = process.env[envKey]; diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 8b2b73c9f0f..bab49155c94 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -120,10 +120,14 @@ function targetsRuntimeWebResolution(params: { function collectConfiguredTargetRefPaths(params: { config: OpenClawConfig; targetIds: Set; + allowedPaths?: ReadonlySet; }): Set { const defaults = params.config.secrets?.defaults; const configuredTargetRefPaths = new Set(); for (const target of discoverConfigSecretTargetsByIds(params.config, params.targetIds)) { + if (params.allowedPaths && !params.allowedPaths.has(target.path)) { + continue; + } const { ref } = resolveSecretInputRef({ value: target.value, refValue: target.refValue, @@ -449,11 +453,13 @@ export async function resolveCommandSecretRefsViaGateway(params: { commandName: string; targetIds: Set; mode?: CommandSecretResolutionModeInput; + allowedPaths?: ReadonlySet; }): Promise { const mode = normalizeCommandSecretResolutionMode(params.mode); const configuredTargetRefPaths = collectConfiguredTargetRefPaths({ config: params.config, targetIds: params.targetIds, + allowedPaths: params.allowedPaths, }); if (configuredTargetRefPaths.size === 0) { return { @@ -498,6 +504,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { targetIds: params.targetIds, preflightDiagnostics: preflight.diagnostics, mode, + allowedPaths: params.allowedPaths, }); const recoveredLocally = Object.values(fallback.targetStatesByPath).some( (state) => state === "resolved_local", @@ -556,6 +563,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { resolvedConfig, targetIds: params.targetIds, inactiveRefPaths, + allowedPaths: params.allowedPaths, }); let diagnostics = dedupeDiagnostics(parsed.diagnostics); const targetStatesByPath = buildTargetStatesByPath({ diff --git a/src/cli/command-secret-resolution.coverage.test.ts b/src/cli/command-secret-resolution.coverage.test.ts index 5508c39792f..fea0fb35eec 100644 --- a/src/cli/command-secret-resolution.coverage.test.ts +++ b/src/cli/command-secret-resolution.coverage.test.ts @@ -14,6 +14,13 @@ const SECRET_TARGET_CALLSITES = [ "src/commands/status.scan.ts", ] as const; +function hasSupportedTargetIdsWiring(source: string): boolean { + return ( + /targetIds:\s*get[A-Za-z0-9_]+\(\)/m.test(source) || + /targetIds:\s*scopedTargets\.targetIds/m.test(source) + ); +} + describe("command secret resolution coverage", () => { it.each(SECRET_TARGET_CALLSITES)( "routes target-id command path through shared gateway resolver: %s", @@ -21,7 +28,7 @@ describe("command secret resolution coverage", () => { const absolutePath = path.join(process.cwd(), relativePath); const source = await fs.readFile(absolutePath, "utf8"); expect(source).toContain("resolveCommandSecretRefsViaGateway"); - expect(source).toContain("targetIds: get"); + expect(hasSupportedTargetIdsWiring(source)).toBe(true); expect(source).toContain("resolveCommandSecretRefsViaGateway({"); }, ); diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index 22a23b36055..5f6a98b70bc 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getAgentRuntimeCommandSecretTargetIds, getMemoryCommandSecretTargetIds, + getScopedChannelsCommandSecretTargets, getSecurityAuditCommandSecretTargetIds, } from "./command-secret-targets.js"; @@ -31,4 +32,83 @@ describe("command secret target ids", () => { expect(ids.has("gateway.remote.token")).toBe(true); expect(ids.has("gateway.remote.password")).toBe(true); }); + + it("scopes channel targets to the requested channel", () => { + const scoped = getScopedChannelsCommandSecretTargets({ + config: {} as never, + channel: "discord", + }); + + expect(scoped.targetIds.size).toBeGreaterThan(0); + expect([...scoped.targetIds].every((id) => id.startsWith("channels.discord."))).toBe(true); + expect([...scoped.targetIds].some((id) => id.startsWith("channels.telegram."))).toBe(false); + }); + + it("does not coerce missing accountId to default when channel is scoped", () => { + const scoped = getScopedChannelsCommandSecretTargets({ + config: { + channels: { + discord: { + defaultAccount: "ops", + accounts: { + ops: { + token: { source: "env", provider: "default", id: "DISCORD_OPS" }, + }, + }, + }, + }, + } as never, + channel: "discord", + }); + + expect(scoped.allowedPaths).toBeUndefined(); + expect(scoped.targetIds.size).toBeGreaterThan(0); + expect([...scoped.targetIds].every((id) => id.startsWith("channels.discord."))).toBe(true); + }); + + it("scopes allowed paths to channel globals + selected account", () => { + const scoped = getScopedChannelsCommandSecretTargets({ + config: { + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_DEFAULT" }, + accounts: { + ops: { + token: { source: "env", provider: "default", id: "DISCORD_OPS" }, + }, + chat: { + token: { source: "env", provider: "default", id: "DISCORD_CHAT" }, + }, + }, + }, + }, + } as never, + channel: "discord", + accountId: "ops", + }); + + expect(scoped.allowedPaths).toBeDefined(); + expect(scoped.allowedPaths?.has("channels.discord.token")).toBe(true); + expect(scoped.allowedPaths?.has("channels.discord.accounts.ops.token")).toBe(true); + expect(scoped.allowedPaths?.has("channels.discord.accounts.chat.token")).toBe(false); + }); + + it("keeps account-scoped allowedPaths as an empty set when scoped target paths are absent", () => { + const scoped = getScopedChannelsCommandSecretTargets({ + config: { + channels: { + discord: { + accounts: { + ops: { enabled: true }, + }, + }, + }, + } as never, + channel: "custom-plugin-channel-without-secret-targets", + accountId: "ops", + }); + + expect(scoped.allowedPaths).toBeDefined(); + expect(scoped.allowedPaths?.size).toBe(0); + }); }); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index d6dde83cd19..89284892f34 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -1,4 +1,9 @@ -import { listSecretTargetRegistryEntries } from "../secrets/target-registry.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { normalizeOptionalAccountId } from "../routing/session-key.js"; +import { + discoverConfigSecretTargetsByIds, + listSecretTargetRegistryEntries, +} from "../secrets/target-registry.js"; function idsByPrefix(prefixes: readonly string[]): string[] { return listSecretTargetRegistryEntries() @@ -37,6 +42,65 @@ function toTargetIdSet(values: readonly string[]): Set { return new Set(values); } +function normalizeScopedChannelId(value?: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function selectChannelTargetIds(channel?: string): Set { + if (!channel) { + return toTargetIdSet(COMMAND_SECRET_TARGETS.channels); + } + return toTargetIdSet( + COMMAND_SECRET_TARGETS.channels.filter((id) => id.startsWith(`channels.${channel}.`)), + ); +} + +function pathTargetsScopedChannelAccount(params: { + pathSegments: readonly string[]; + channel: string; + accountId: string; +}): boolean { + const [root, channelId, accountRoot, accountId] = params.pathSegments; + if (root !== "channels" || channelId !== params.channel) { + return false; + } + if (accountRoot !== "accounts") { + return true; + } + return accountId === params.accountId; +} + +export function getScopedChannelsCommandSecretTargets(params: { + config: OpenClawConfig; + channel?: string | null; + accountId?: string | null; +}): { + targetIds: Set; + allowedPaths?: Set; +} { + const channel = normalizeScopedChannelId(params.channel); + const targetIds = selectChannelTargetIds(channel); + const normalizedAccountId = normalizeOptionalAccountId(params.accountId); + if (!channel || !normalizedAccountId) { + return { targetIds }; + } + + const allowedPaths = new Set(); + for (const target of discoverConfigSecretTargetsByIds(params.config, targetIds)) { + if ( + pathTargetsScopedChannelAccount({ + pathSegments: target.pathSegments, + channel, + accountId: normalizedAccountId, + }) + ) { + allowedPaths.add(target.path); + } + } + return { targetIds, allowedPaths }; +} + export function getMemoryCommandSecretTargetIds(): Set { return toTargetIdSet(COMMAND_SECRET_TARGETS.memory); } diff --git a/src/cli/message-secret-scope.test.ts b/src/cli/message-secret-scope.test.ts new file mode 100644 index 00000000000..9e243f48b7c --- /dev/null +++ b/src/cli/message-secret-scope.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { resolveMessageSecretScope } from "./message-secret-scope.js"; + +describe("resolveMessageSecretScope", () => { + it("prefers explicit channel/account inputs", () => { + expect( + resolveMessageSecretScope({ + channel: "Discord", + accountId: "Ops", + }), + ).toEqual({ + channel: "discord", + accountId: "ops", + }); + }); + + it("infers channel from a prefixed target", () => { + expect( + resolveMessageSecretScope({ + target: "telegram:12345", + }), + ).toEqual({ + channel: "telegram", + }); + }); + + it("infers a shared channel from target arrays", () => { + expect( + resolveMessageSecretScope({ + targets: ["discord:one", "discord:two"], + }), + ).toEqual({ + channel: "discord", + }); + }); + + it("does not infer a channel when target arrays mix channels", () => { + expect( + resolveMessageSecretScope({ + targets: ["discord:one", "slack:two"], + }), + ).toEqual({}); + }); + + it("uses fallback channel/account when direct inputs are missing", () => { + expect( + resolveMessageSecretScope({ + fallbackChannel: "Signal", + fallbackAccountId: "Chat", + }), + ).toEqual({ + channel: "signal", + accountId: "chat", + }); + }); +}); diff --git a/src/cli/message-secret-scope.ts b/src/cli/message-secret-scope.ts new file mode 100644 index 00000000000..5dd72655ec6 --- /dev/null +++ b/src/cli/message-secret-scope.ts @@ -0,0 +1,83 @@ +import { normalizeAccountId } from "../routing/session-key.js"; +import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js"; + +function resolveScopedChannelCandidate(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = normalizeMessageChannel(value); + if (!normalized || !isDeliverableMessageChannel(normalized)) { + return undefined; + } + return normalized; +} + +function resolveChannelFromTargetValue(target: unknown): string | undefined { + if (typeof target !== "string") { + return undefined; + } + const trimmed = target.trim(); + if (!trimmed) { + return undefined; + } + const separator = trimmed.indexOf(":"); + if (separator <= 0) { + return undefined; + } + return resolveScopedChannelCandidate(trimmed.slice(0, separator)); +} + +function resolveChannelFromTargets(targets: unknown): string | undefined { + if (!Array.isArray(targets)) { + return undefined; + } + const seen = new Set(); + for (const target of targets) { + const channel = resolveChannelFromTargetValue(target); + if (channel) { + seen.add(channel); + } + } + if (seen.size !== 1) { + return undefined; + } + return [...seen][0]; +} + +function resolveScopedAccountId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + return normalizeAccountId(trimmed); +} + +export function resolveMessageSecretScope(params: { + channel?: unknown; + target?: unknown; + targets?: unknown; + fallbackChannel?: string | null; + accountId?: unknown; + fallbackAccountId?: string | null; +}): { + channel?: string; + accountId?: string; +} { + const channel = + resolveScopedChannelCandidate(params.channel) ?? + resolveChannelFromTargetValue(params.target) ?? + resolveChannelFromTargets(params.targets) ?? + resolveScopedChannelCandidate(params.fallbackChannel); + + const accountId = + resolveScopedAccountId(params.accountId) ?? + resolveScopedAccountId(params.fallbackAccountId ?? undefined); + + return { + ...(channel ? { channel } : {}), + ...(accountId ? { accountId } : {}), + }; +} diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index a1b204b5990..39e7b9d00fe 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -387,6 +387,61 @@ describe("doctor config flow", () => { } }); + it("warns and continues when Telegram account inspection hits inactive SecretRef surfaces", async () => { + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + try { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + channels: { + telegram: { + accounts: { + inactive: { + enabled: false, + botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }, + allowFrom: ["@testuser"], + }, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const cfg = result.cfg as { + channels?: { + telegram?: { + accounts?: Record; + }; + }; + }; + expect(cfg.channels?.telegram?.accounts?.inactive?.allowFrom).toEqual(["@testuser"]); + expect(fetchSpy).not.toHaveBeenCalled(); + expect( + noteSpy.mock.calls.some((call) => + String(call[0]).includes("Telegram account inactive: failed to inspect bot token"), + ), + ).toBe(true); + expect( + noteSpy.mock.calls.some((call) => + String(call[0]).includes( + "Telegram allowFrom contains @username entries, but no Telegram bot token is configured", + ), + ), + ).toBe(true); + } finally { + noteSpy.mockRestore(); + vi.unstubAllGlobals(); + } + }); + it("converts numeric discord ids to strings on repair", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".openclaw"); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 912869f390b..ae755423987 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -40,6 +40,7 @@ import { normalizeAccountId, normalizeOptionalAccountId, } from "../routing/session-key.js"; +import { describeUnknownError } from "../secrets/shared.js"; import { isDiscordMutableAllowEntry, isGoogleChatMutableAllowEntry, @@ -334,10 +335,23 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi const inspected = inspectTelegramAccount({ cfg, accountId }); return inspected.enabled && inspected.tokenStatus === "configured_unavailable"; }); + const tokenResolutionWarnings: string[] = []; const tokens = Array.from( new Set( listTelegramAccountIds(resolvedConfig) - .map((accountId) => resolveTelegramAccount({ cfg: resolvedConfig, accountId })) + .map((accountId) => { + try { + return resolveTelegramAccount({ cfg: resolvedConfig, accountId }); + } catch (error) { + tokenResolutionWarnings.push( + `- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`, + ); + return null; + } + }) + .filter((account): account is NonNullable> => + Boolean(account), + ) .map((account) => (account.tokenSource === "none" ? "" : account.token)) .map((token) => token.trim()) .filter(Boolean), @@ -348,6 +362,7 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi return { config: cfg, changes: [ + ...tokenResolutionWarnings, hasConfiguredUnavailableToken ? `- Telegram allowFrom contains @username entries, but configured Telegram bot credentials are unavailable in this command path; cannot auto-resolve (start the gateway or make the secret source available, then rerun doctor --fix).` : `- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run setup or replace with numeric sender IDs).`, diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index adbe4ae7850..182946ba7ad 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -301,6 +301,13 @@ describe("messageCommand", () => { commandName: "message", }), ); + const secretResolveCall = resolveCommandSecretRefsViaGateway.mock.calls[0]?.[0] as { + targetIds?: Set; + }; + expect(secretResolveCall.targetIds).toBeInstanceOf(Set); + expect( + [...(secretResolveCall.targetIds ?? [])].every((id) => id.startsWith("channels.telegram.")), + ).toBe(true); expect(handleTelegramAction).toHaveBeenCalledWith( expect.objectContaining({ action: "send", to: "123456", accountId: undefined }), resolvedConfig, diff --git a/src/commands/message.ts b/src/commands/message.ts index 76e622e2cf3..52540e8916d 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -3,7 +3,8 @@ import { type ChannelMessageActionName, } from "../channels/plugins/types.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; -import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js"; +import { getScopedChannelsCommandSecretTargets } from "../cli/command-secret-targets.js"; +import { resolveMessageSecretScope } from "../cli/message-secret-scope.js"; import { createOutboundSendDeps, type CliDeps } from "../cli/outbound-send-deps.js"; import { withProgress } from "../cli/progress.js"; import { loadConfig } from "../config/config.js"; @@ -19,10 +20,22 @@ export async function messageCommand( runtime: RuntimeEnv, ) { const loadedRaw = loadConfig(); + const scope = resolveMessageSecretScope({ + channel: opts.channel, + target: opts.target, + targets: opts.targets, + accountId: opts.accountId, + }); + const scopedTargets = getScopedChannelsCommandSecretTargets({ + config: loadedRaw, + channel: scope.channel, + accountId: scope.accountId, + }); const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({ config: loadedRaw, commandName: "message", - targetIds: getChannelsCommandSecretTargetIds(), + targetIds: scopedTargets.targetIds, + ...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}), }); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index b643c30ff33..3ef91457a50 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -44,12 +44,13 @@ export async function statusAllCommand( await withProgress({ label: "Scanning status --all…", total: 11 }, async (progress) => { progress.setLabel("Loading config…"); const loadedRaw = await readBestEffortConfig(); - const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({ - config: loadedRaw, - commandName: "status --all", - targetIds: getStatusCommandSecretTargetIds(), - mode: "read_only_status", - }); + const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = + await resolveCommandSecretRefsViaGateway({ + config: loadedRaw, + commandName: "status --all", + targetIds: getStatusCommandSecretTargetIds(), + mode: "read_only_status", + }); const osSummary = resolveOsSummary(); const snap = await readConfigFileSnapshot().catch(() => null); progress.tick(); @@ -328,6 +329,13 @@ export async function statusAllCommand( Item: "Agents", Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`, }, + { + Item: "Secrets", + Value: + secretDiagnostics.length > 0 + ? `${secretDiagnostics.length} diagnostic${secretDiagnostics.length === 1 ? "" : "s"}` + : "none", + }, ]; const lines = await buildStatusAllReportLines({ @@ -343,6 +351,7 @@ export async function statusAllCommand( diagnosis: { snap, remoteUrlMissing, + secretDiagnostics, sentinel, lastErr, port, diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index 59140e49b44..5b866413021 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -50,6 +50,7 @@ export async function appendStatusAllDiagnosis(params: { connectionDetailsForReport: string; snap: ConfigSnapshotLike | null; remoteUrlMissing: boolean; + secretDiagnostics: string[]; sentinel: { payload?: RestartSentinelPayload | null } | null; lastErr: string | null; port: number; @@ -104,6 +105,17 @@ export async function appendStatusAllDiagnosis(params: { lines.push(` ${muted("Fix: set gateway.remote.url, or set gateway.mode=local.")}`); } + emitCheck( + `Secret diagnostics (${params.secretDiagnostics.length})`, + params.secretDiagnostics.length === 0 ? "ok" : "warn", + ); + for (const diagnostic of params.secretDiagnostics.slice(0, 10)) { + lines.push(` - ${muted(redactSecrets(diagnostic))}`); + } + if (params.secretDiagnostics.length > 10) { + lines.push(` ${muted(`… +${params.secretDiagnostics.length - 10} more`)}`); + } + if (params.sentinel?.payload) { emitCheck("Restart sentinel present", "warn"); lines.push( diff --git a/src/commands/status-all/report-lines.test.ts b/src/commands/status-all/report-lines.test.ts index 5769bc0d41d..0a71665224c 100644 --- a/src/commands/status-all/report-lines.test.ts +++ b/src/commands/status-all/report-lines.test.ts @@ -46,6 +46,7 @@ describe("buildStatusAllReportLines", () => { diagnosis: { snap: null, remoteUrlMissing: false, + secretDiagnostics: [], sentinel: null, lastErr: null, port: 18789, @@ -70,5 +71,10 @@ describe("buildStatusAllReportLines", () => { expect(output).toContain("Bootstrap file"); expect(output).toContain("PRESENT"); expect(output).toContain("ABSENT"); + expect(diagnosisSpy).toHaveBeenCalledWith( + expect.objectContaining({ + secretDiagnostics: [], + }), + ); }); }); diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index 9448b919312..5f3ac319628 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultRuntime } from "../../runtime.js"; const mocks = vi.hoisted(() => ({ listChannelPlugins: vi.fn(), @@ -14,6 +15,7 @@ vi.mock("./channel-resolution.js", () => ({ })); import { + __testing, listConfiguredMessageChannels, resolveMessageChannelSelection, } from "./channel-selection.js"; @@ -38,6 +40,8 @@ function makePlugin(params: { } describe("listConfiguredMessageChannels", () => { + const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); + beforeEach(() => { mocks.listChannelPlugins.mockReset(); mocks.listChannelPlugins.mockReturnValue([]); @@ -45,6 +49,8 @@ describe("listConfiguredMessageChannels", () => { mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({ id: channel, })); + __testing.resetLoggedChannelSelectionErrors(); + errorSpy.mockClear(); }); it("skips unknown plugin ids and plugins without accounts", async () => { @@ -93,6 +99,20 @@ describe("listConfiguredMessageChannels", () => { await expect(listConfiguredMessageChannels({} as never)).resolves.toEqual([]); }); + + it("skips plugin accounts whose resolveAccount throws", async () => { + mocks.listChannelPlugins.mockReturnValue([ + makePlugin({ + id: "discord", + resolveAccount: () => { + throw new Error("boom"); + }, + }), + ]); + + await expect(listConfiguredMessageChannels({} as never)).resolves.toEqual([]); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); }); describe("resolveMessageChannelSelection", () => { diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index 024fc2273f6..0e87a8e4950 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -1,6 +1,7 @@ import { listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { defaultRuntime } from "../../runtime.js"; import { listDeliverableMessageChannels, type DeliverableMessageChannel, @@ -59,6 +60,25 @@ function isAccountEnabled(account: unknown): boolean { return enabled !== false; } +const loggedChannelSelectionErrors = new Set(); + +function logChannelSelectionError(params: { + pluginId: string; + accountId: string; + operation: "resolveAccount" | "isConfigured"; + error: unknown; +}) { + const message = params.error instanceof Error ? params.error.message : String(params.error); + const key = `${params.pluginId}:${params.accountId}:${params.operation}:${message}`; + if (loggedChannelSelectionErrors.has(key)) { + return; + } + loggedChannelSelectionErrors.add(key); + defaultRuntime.error?.( + `[channel-selection] ${params.pluginId}(${params.accountId}) ${params.operation} failed: ${message}`, + ); +} + async function isPluginConfigured(plugin: ChannelPlugin, cfg: OpenClawConfig): Promise { const accountIds = plugin.config.listAccountIds(cfg); if (accountIds.length === 0) { @@ -66,7 +86,18 @@ async function isPluginConfigured(plugin: ChannelPlugin, cfg: OpenClawConfig): P } for (const accountId of accountIds) { - const account = plugin.config.resolveAccount(cfg, accountId); + let account: unknown; + try { + account = plugin.config.resolveAccount(cfg, accountId); + } catch (error) { + logChannelSelectionError({ + pluginId: plugin.id, + accountId, + operation: "resolveAccount", + error, + }); + continue; + } const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : isAccountEnabled(account); @@ -76,7 +107,18 @@ async function isPluginConfigured(plugin: ChannelPlugin, cfg: OpenClawConfig): P if (!plugin.config.isConfigured) { return true; } - const configured = await plugin.config.isConfigured(account, cfg); + let configured = false; + try { + configured = await plugin.config.isConfigured(account, cfg); + } catch (error) { + logChannelSelectionError({ + pluginId: plugin.id, + accountId, + operation: "isConfigured", + error, + }); + continue; + } if (configured) { return true; } @@ -162,3 +204,9 @@ export async function resolveMessageChannelSelection(params: { `Channel is required when multiple channels are configured: ${configured.join(", ")}`, ); } + +export const __testing = { + resetLoggedChannelSelectionErrors() { + loggedChannelSelectionErrors.clear(); + }, +}; From 880bc969f90ba8c0a4f8f6b5ef52f6e9b5728638 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 22:11:35 -0700 Subject: [PATCH 104/128] refactor: move plugin sdk setup helpers out of commands --- src/commands/ollama-setup.ts | 532 +---------------- src/commands/self-hosted-provider-setup.ts | 305 +--------- src/commands/signal-install.ts | 303 +--------- src/commands/vllm-setup.ts | 43 +- src/commands/zai-endpoint-detect.ts | 180 +----- src/plugin-sdk-internal/setup.ts | 2 + src/plugin-sdk/agent-runtime.ts | 1 + src/plugin-sdk/index.ts | 10 +- src/plugin-sdk/ollama-setup.ts | 2 +- src/plugin-sdk/provider-setup.ts | 6 +- src/plugin-sdk/self-hosted-provider-setup.ts | 2 +- src/plugin-sdk/setup.ts | 4 +- src/plugin-sdk/zai.ts | 2 +- .../contracts/auth-choice.contract.test.ts | 3 +- src/plugins/provider-ollama-setup.ts | 535 ++++++++++++++++++ src/plugins/provider-self-hosted-setup.ts | 304 ++++++++++ src/plugins/provider-vllm-setup.ts | 42 ++ src/plugins/provider-zai-endpoint.ts | 179 ++++++ src/plugins/setup-binary.ts | 36 ++ src/plugins/setup-browser.ts | 112 ++++ src/plugins/signal-cli-install.ts | 302 ++++++++++ 21 files changed, 1532 insertions(+), 1373 deletions(-) create mode 100644 src/plugins/provider-ollama-setup.ts create mode 100644 src/plugins/provider-self-hosted-setup.ts create mode 100644 src/plugins/provider-vllm-setup.ts create mode 100644 src/plugins/provider-zai-endpoint.ts create mode 100644 src/plugins/setup-binary.ts create mode 100644 src/plugins/setup-browser.ts create mode 100644 src/plugins/signal-cli-install.ts diff --git a/src/commands/ollama-setup.ts b/src/commands/ollama-setup.ts index 31499d3f0a6..9be1fcf6c31 100644 --- a/src/commands/ollama-setup.ts +++ b/src/commands/ollama-setup.ts @@ -1,531 +1 @@ -import { upsertAuthProfileWithLock } from "../agents/auth-profiles/upsert-with-lock.js"; -import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; -import { - buildOllamaModelDefinition, - enrichOllamaModelsWithContext, - fetchOllamaModels, - resolveOllamaApiBase, - type OllamaModelWithContext, -} from "../agents/ollama-models.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultModelPrimary } from "../plugins/provider-onboarding-config.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { WizardCancelledError, type WizardPrompter } from "../wizard/prompts.js"; -import { isRemoteEnvironment } from "./oauth-env.js"; -import { openUrl } from "./onboard-helpers.js"; -import type { OnboardMode, OnboardOptions } from "./onboard-types.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"]; -const OLLAMA_SUGGESTED_MODELS_CLOUD = ["kimi-k2.5:cloud", "minimax-m2.5:cloud", "glm-5:cloud"]; - -function normalizeOllamaModelName(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) { - return undefined; - } - if (trimmed.toLowerCase().startsWith("ollama/")) { - const withoutPrefix = trimmed.slice("ollama/".length).trim(); - return withoutPrefix || undefined; - } - return trimmed; -} - -function isOllamaCloudModel(modelName: string | undefined): boolean { - return Boolean(modelName?.trim().toLowerCase().endsWith(":cloud")); -} - -function formatOllamaPullStatus(status: string): { text: string; hidePercent: boolean } { - const trimmed = status.trim(); - const partStatusMatch = trimmed.match(/^([a-z-]+)\s+(?:sha256:)?[a-f0-9]{8,}$/i); - if (partStatusMatch) { - return { text: `${partStatusMatch[1]} part`, hidePercent: false }; - } - if (/^verifying\b.*\bdigest\b/i.test(trimmed)) { - return { text: "verifying digest", hidePercent: true }; - } - return { text: trimmed, hidePercent: false }; -} - -type OllamaCloudAuthResult = { - signedIn: boolean; - signinUrl?: string; -}; - -/** Check if the user is signed in to Ollama cloud via /api/me. */ -async function checkOllamaCloudAuth(baseUrl: string): Promise { - try { - const apiBase = resolveOllamaApiBase(baseUrl); - const response = await fetch(`${apiBase}/api/me`, { - method: "POST", - signal: AbortSignal.timeout(5000), - }); - if (response.status === 401) { - // 401 body contains { error, signin_url } - const data = (await response.json()) as { signin_url?: string }; - return { signedIn: false, signinUrl: data.signin_url }; - } - if (!response.ok) { - return { signedIn: false }; - } - return { signedIn: true }; - } catch { - // /api/me not supported or unreachable — fail closed so cloud mode - // doesn't silently skip auth; the caller handles the fallback. - return { signedIn: false }; - } -} - -type OllamaPullChunk = { - status?: string; - total?: number; - completed?: number; - error?: string; -}; - -type OllamaPullFailureKind = "http" | "no-body" | "chunk-error" | "network"; -type OllamaPullResult = - | { ok: true } - | { - ok: false; - kind: OllamaPullFailureKind; - message: string; - }; - -async function pullOllamaModelCore(params: { - baseUrl: string; - modelName: string; - onStatus?: (status: string, percent: number | null) => void; -}): Promise { - const { onStatus } = params; - const baseUrl = resolveOllamaApiBase(params.baseUrl); - const modelName = normalizeOllamaModelName(params.modelName) ?? params.modelName.trim(); - try { - const response = await fetch(`${baseUrl}/api/pull`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: modelName }), - }); - if (!response.ok) { - return { - ok: false, - kind: "http", - message: `Failed to download ${modelName} (HTTP ${response.status})`, - }; - } - if (!response.body) { - return { - ok: false, - kind: "no-body", - message: `Failed to download ${modelName} (no response body)`, - }; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - const layers = new Map(); - - const parseLine = (line: string): OllamaPullResult => { - const trimmed = line.trim(); - if (!trimmed) { - return { ok: true }; - } - try { - const chunk = JSON.parse(trimmed) as OllamaPullChunk; - if (chunk.error) { - return { - ok: false, - kind: "chunk-error", - message: `Download failed: ${chunk.error}`, - }; - } - if (!chunk.status) { - return { ok: true }; - } - if (chunk.total && chunk.completed !== undefined) { - layers.set(chunk.status, { total: chunk.total, completed: chunk.completed }); - let totalSum = 0; - let completedSum = 0; - for (const layer of layers.values()) { - totalSum += layer.total; - completedSum += layer.completed; - } - const percent = totalSum > 0 ? Math.round((completedSum / totalSum) * 100) : null; - onStatus?.(chunk.status, percent); - } else { - onStatus?.(chunk.status, null); - } - } catch { - // Ignore malformed lines from streaming output. - } - return { ok: true }; - }; - - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; - } - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - const parsed = parseLine(line); - if (!parsed.ok) { - return parsed; - } - } - } - - const trailing = buffer.trim(); - if (trailing) { - const parsed = parseLine(trailing); - if (!parsed.ok) { - return parsed; - } - } - - return { ok: true }; - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - return { - ok: false, - kind: "network", - message: `Failed to download ${modelName}: ${reason}`, - }; - } -} - -/** Pull a model from Ollama, streaming progress updates. */ -async function pullOllamaModel( - baseUrl: string, - modelName: string, - prompter: WizardPrompter, -): Promise { - const spinner = prompter.progress(`Downloading ${modelName}...`); - const result = await pullOllamaModelCore({ - baseUrl, - modelName, - onStatus: (status, percent) => { - const displayStatus = formatOllamaPullStatus(status); - if (displayStatus.hidePercent) { - spinner.update(`Downloading ${modelName} - ${displayStatus.text}`); - } else { - spinner.update(`Downloading ${modelName} - ${displayStatus.text} - ${percent ?? 0}%`); - } - }, - }); - if (!result.ok) { - spinner.stop(result.message); - return false; - } - spinner.stop(`Downloaded ${modelName}`); - return true; -} - -async function pullOllamaModelNonInteractive( - baseUrl: string, - modelName: string, - runtime: RuntimeEnv, -): Promise { - runtime.log(`Downloading ${modelName}...`); - const result = await pullOllamaModelCore({ baseUrl, modelName }); - if (!result.ok) { - runtime.error(result.message); - return false; - } - runtime.log(`Downloaded ${modelName}`); - return true; -} - -function buildOllamaModelsConfig( - modelNames: string[], - discoveredModelsByName?: Map, -) { - return modelNames.map((name) => - buildOllamaModelDefinition(name, discoveredModelsByName?.get(name)?.contextWindow), - ); -} - -function applyOllamaProviderConfig( - cfg: OpenClawConfig, - baseUrl: string, - modelNames: string[], - discoveredModelsByName?: Map, -): OpenClawConfig { - return { - ...cfg, - models: { - ...cfg.models, - mode: cfg.models?.mode ?? "merge", - providers: { - ...cfg.models?.providers, - ollama: { - baseUrl, - api: "ollama", - apiKey: "OLLAMA_API_KEY", // pragma: allowlist secret - models: buildOllamaModelsConfig(modelNames, discoveredModelsByName), - }, - }, - }, - }; -} - -async function storeOllamaCredential(agentDir?: string): Promise { - await upsertAuthProfileWithLock({ - profileId: "ollama:default", - credential: { type: "api_key", provider: "ollama", key: "ollama-local" }, - agentDir, - }); -} - -/** - * Interactive: prompt for base URL, discover models, configure provider. - * Model selection is handled by the standard model picker downstream. - */ -export async function promptAndConfigureOllama(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; -}): Promise<{ config: OpenClawConfig; defaultModelId: string }> { - const { prompter } = params; - - // 1. Prompt base URL - const baseUrlRaw = await prompter.text({ - message: "Ollama base URL", - initialValue: OLLAMA_DEFAULT_BASE_URL, - placeholder: OLLAMA_DEFAULT_BASE_URL, - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - const configuredBaseUrl = String(baseUrlRaw ?? "") - .trim() - .replace(/\/+$/, ""); - const baseUrl = resolveOllamaApiBase(configuredBaseUrl); - - // 2. Check reachability - const { reachable, models } = await fetchOllamaModels(baseUrl); - - if (!reachable) { - await prompter.note( - [ - `Ollama could not be reached at ${baseUrl}.`, - "Download it at https://ollama.com/download", - "", - "Start Ollama and re-run setup.", - ].join("\n"), - "Ollama", - ); - throw new WizardCancelledError("Ollama not reachable"); - } - - const enrichedModels = await enrichOllamaModelsWithContext(baseUrl, models.slice(0, 50)); - const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model])); - const modelNames = models.map((m) => m.name); - - // 3. Mode selection - const mode = (await prompter.select({ - message: "Ollama mode", - options: [ - { value: "remote", label: "Cloud + Local", hint: "Ollama cloud models + local models" }, - { value: "local", label: "Local", hint: "Local models only" }, - ], - })) as OnboardMode; - - // 4. Cloud auth — check /api/me upfront for remote (cloud+local) mode - let cloudAuthVerified = false; - if (mode === "remote") { - const authResult = await checkOllamaCloudAuth(baseUrl); - if (!authResult.signedIn) { - if (authResult.signinUrl) { - if (!isRemoteEnvironment()) { - await openUrl(authResult.signinUrl); - } - await prompter.note( - ["Sign in to Ollama Cloud:", authResult.signinUrl].join("\n"), - "Ollama Cloud", - ); - const confirmed = await prompter.confirm({ - message: "Have you signed in?", - }); - if (!confirmed) { - throw new WizardCancelledError("Ollama cloud sign-in cancelled"); - } - // Re-check after user claims sign-in - const recheck = await checkOllamaCloudAuth(baseUrl); - if (!recheck.signedIn) { - throw new WizardCancelledError("Ollama cloud sign-in required"); - } - cloudAuthVerified = true; - } else { - // No signin URL available (older server, unreachable /api/me, or custom gateway). - await prompter.note( - [ - "Could not verify Ollama Cloud authentication.", - "Cloud models may not work until you sign in at https://ollama.com.", - ].join("\n"), - "Ollama Cloud", - ); - const continueAnyway = await prompter.confirm({ - message: "Continue without cloud auth?", - }); - if (!continueAnyway) { - throw new WizardCancelledError("Ollama cloud auth could not be verified"); - } - // Cloud auth unverified — fall back to local defaults so the model - // picker doesn't steer toward cloud models that may fail. - } - } else { - cloudAuthVerified = true; - } - } - - // 5. Model ordering — suggested models first. - // Use cloud defaults only when auth was actually verified; otherwise fall - // back to local defaults so the user isn't steered toward cloud models - // that may fail at runtime. - const suggestedModels = - mode === "local" || !cloudAuthVerified - ? OLLAMA_SUGGESTED_MODELS_LOCAL - : OLLAMA_SUGGESTED_MODELS_CLOUD; - const orderedModelNames = [ - ...suggestedModels, - ...modelNames.filter((name) => !suggestedModels.includes(name)), - ]; - - const defaultModelId = suggestedModels[0] ?? OLLAMA_DEFAULT_MODEL; - const config = applyOllamaProviderConfig( - params.cfg, - baseUrl, - orderedModelNames, - discoveredModelsByName, - ); - return { config, defaultModelId }; -} - -/** Non-interactive: auto-discover models and configure provider. */ -export async function configureOllamaNonInteractive(params: { - nextConfig: OpenClawConfig; - opts: OnboardOptions; - runtime: RuntimeEnv; -}): Promise { - const { opts, runtime } = params; - const configuredBaseUrl = (opts.customBaseUrl?.trim() || OLLAMA_DEFAULT_BASE_URL).replace( - /\/+$/, - "", - ); - const baseUrl = resolveOllamaApiBase(configuredBaseUrl); - - const { reachable, models } = await fetchOllamaModels(baseUrl); - const explicitModel = normalizeOllamaModelName(opts.customModelId); - - if (!reachable) { - runtime.error( - [ - `Ollama could not be reached at ${baseUrl}.`, - "Download it at https://ollama.com/download", - ].join("\n"), - ); - runtime.exit(1); - return params.nextConfig; - } - - await storeOllamaCredential(); - - const enrichedModels = await enrichOllamaModelsWithContext(baseUrl, models.slice(0, 50)); - const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model])); - const modelNames = models.map((m) => m.name); - - // Apply local suggested model ordering. - const suggestedModels = OLLAMA_SUGGESTED_MODELS_LOCAL; - const orderedModelNames = [ - ...suggestedModels, - ...modelNames.filter((name) => !suggestedModels.includes(name)), - ]; - - const requestedDefaultModelId = explicitModel ?? suggestedModels[0]; - let pulledRequestedModel = false; - const availableModelNames = new Set(modelNames); - const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId); - - if (requestedCloudModel) { - availableModelNames.add(requestedDefaultModelId); - } - - // Pull if model not in discovered list and Ollama is reachable - if (!requestedCloudModel && !modelNames.includes(requestedDefaultModelId)) { - pulledRequestedModel = await pullOllamaModelNonInteractive( - baseUrl, - requestedDefaultModelId, - runtime, - ); - if (pulledRequestedModel) { - availableModelNames.add(requestedDefaultModelId); - } - } - - let allModelNames = orderedModelNames; - let defaultModelId = requestedDefaultModelId; - if ( - (pulledRequestedModel || requestedCloudModel) && - !allModelNames.includes(requestedDefaultModelId) - ) { - allModelNames = [...allModelNames, requestedDefaultModelId]; - } - if (!availableModelNames.has(requestedDefaultModelId)) { - if (availableModelNames.size > 0) { - const firstAvailableModel = - allModelNames.find((name) => availableModelNames.has(name)) ?? - Array.from(availableModelNames)[0]; - defaultModelId = firstAvailableModel; - runtime.log( - `Ollama model ${requestedDefaultModelId} was not available; using ${defaultModelId} instead.`, - ); - } else { - runtime.error( - [ - `No Ollama models are available at ${baseUrl}.`, - "Pull a model first, then re-run setup.", - ].join("\n"), - ); - runtime.exit(1); - return params.nextConfig; - } - } - - const config = applyOllamaProviderConfig( - params.nextConfig, - baseUrl, - allModelNames, - discoveredModelsByName, - ); - const modelRef = `ollama/${defaultModelId}`; - runtime.log(`Default Ollama model: ${defaultModelId}`); - return applyAgentDefaultModelPrimary(config, modelRef); -} - -/** Pull the configured default Ollama model if it isn't already available locally. */ -export async function ensureOllamaModelPulled(params: { - config: OpenClawConfig; - prompter: WizardPrompter; -}): Promise { - const modelCfg = params.config.agents?.defaults?.model; - const modelId = typeof modelCfg === "string" ? modelCfg : modelCfg?.primary; - if (!modelId?.startsWith("ollama/")) { - return; - } - const baseUrl = params.config.models?.providers?.ollama?.baseUrl ?? OLLAMA_DEFAULT_BASE_URL; - const modelName = modelId.slice("ollama/".length); - if (isOllamaCloudModel(modelName)) { - return; - } - const { models } = await fetchOllamaModels(baseUrl); - if (models.some((m) => m.name === modelName)) { - return; - } - const pulled = await pullOllamaModel(baseUrl, modelName, params.prompter); - if (!pulled) { - throw new WizardCancelledError("Failed to download selected Ollama model"); - } -} +export * from "../plugins/provider-ollama-setup.js"; diff --git a/src/commands/self-hosted-provider-setup.ts b/src/commands/self-hosted-provider-setup.ts index 2b1e0a3027b..8d4e85fa8ff 100644 --- a/src/commands/self-hosted-provider-setup.ts +++ b/src/commands/self-hosted-provider-setup.ts @@ -1,304 +1 @@ -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 { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; -import type { - ProviderDiscoveryContext, - ProviderAuthResult, - ProviderAuthMethodNonInteractiveContext, - ProviderNonInteractiveApiKeyResult, -} from "../plugins/types.js"; -import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; - -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; - const fallbacks = - existingModel && typeof existingModel === "object" && "fallbacks" in existingModel - ? (existingModel as { fallbacks?: string[] }).fallbacks - : undefined; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - model: { - ...(fallbacks ? { fallbacks } : undefined), - primary: modelRef, - }, - }, - }, - }; -} - -function buildOpenAICompatibleSelfHostedProviderConfig(params: { - cfg: OpenClawConfig; - providerId: string; - baseUrl: string; - providerApiKey: string; - modelId: string; - input?: Array<"text" | "image">; - reasoning?: boolean; - contextWindow?: number; - maxTokens?: number; -}): { config: OpenClawConfig; modelId: string; modelRef: string; profileId: string } { - const modelRef = `${params.providerId}/${params.modelId}`; - const profileId = `${params.providerId}:default`; - return { - config: { - ...params.cfg, - models: { - ...params.cfg.models, - mode: params.cfg.models?.mode ?? "merge", - providers: { - ...params.cfg.models?.providers, - [params.providerId]: { - baseUrl: params.baseUrl, - api: "openai-completions", - apiKey: params.providerApiKey, - models: [ - { - id: params.modelId, - name: params.modelId, - reasoning: params.reasoning ?? false, - input: params.input ?? ["text"], - cost: SELF_HOSTED_DEFAULT_COST, - contextWindow: params.contextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, - maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS, - }, - ], - }, - }, - }, - }, - modelId: params.modelId, - modelRef, - profileId, - }; -} - -type OpenAICompatibleSelfHostedProviderSetupParams = { - cfg: OpenClawConfig; - prompter: WizardPrompter; - providerId: string; - providerLabel: string; - defaultBaseUrl: string; - defaultApiKeyEnvVar: string; - modelPlaceholder: string; - input?: Array<"text" | "image">; - reasoning?: boolean; - contextWindow?: number; - maxTokens?: number; -}; - -type OpenAICompatibleSelfHostedProviderPromptResult = { - config: OpenClawConfig; - credential: AuthProfileCredential; - modelId: string; - modelRef: string; - profileId: string; -}; - -function buildSelfHostedProviderAuthResult( - result: OpenAICompatibleSelfHostedProviderPromptResult, -): ProviderAuthResult { - return { - profiles: [ - { - profileId: result.profileId, - credential: result.credential, - }, - ], - configPatch: result.config, - defaultModel: result.modelRef, - }; -} - -export async function promptAndConfigureOpenAICompatibleSelfHostedProvider( - params: OpenAICompatibleSelfHostedProviderSetupParams, -): Promise { - const baseUrlRaw = await params.prompter.text({ - message: `${params.providerLabel} base URL`, - initialValue: params.defaultBaseUrl, - placeholder: params.defaultBaseUrl, - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - const apiKeyRaw = await params.prompter.text({ - message: `${params.providerLabel} API key`, - placeholder: "sk-... (or any non-empty string)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - const modelIdRaw = await params.prompter.text({ - message: `${params.providerLabel} model`, - placeholder: params.modelPlaceholder, - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - - const baseUrl = String(baseUrlRaw ?? "") - .trim() - .replace(/\/+$/, ""); - const apiKey = String(apiKeyRaw ?? "").trim(); - const modelId = String(modelIdRaw ?? "").trim(); - const credential: AuthProfileCredential = { - type: "api_key", - provider: params.providerId, - key: apiKey, - }; - const configured = buildOpenAICompatibleSelfHostedProviderConfig({ - cfg: params.cfg, - providerId: params.providerId, - baseUrl, - providerApiKey: params.defaultApiKeyEnvVar, - modelId, - input: params.input, - reasoning: params.reasoning, - contextWindow: params.contextWindow, - maxTokens: params.maxTokens, - }); - - return { - config: configured.config, - credential, - modelId: configured.modelId, - modelRef: configured.modelRef, - profileId: configured.profileId, - }; -} - -export async function promptAndConfigureOpenAICompatibleSelfHostedProviderAuth( - params: OpenAICompatibleSelfHostedProviderSetupParams, -): Promise { - const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider(params); - return buildSelfHostedProviderAuthResult(result); -} - -export async function discoverOpenAICompatibleSelfHostedProvider< - T extends Record, ->(params: { - ctx: ProviderDiscoveryContext; - providerId: string; - buildProvider: (params: { apiKey?: string }) => Promise; -}): Promise<{ provider: T & { apiKey: string } } | null> { - if (params.ctx.config.models?.providers?.[params.providerId]) { - return null; - } - const { apiKey, discoveryApiKey } = params.ctx.resolveProviderApiKey(params.providerId); - if (!apiKey) { - return null; - } - return { - provider: { - ...(await params.buildProvider({ apiKey: discoveryApiKey })), - apiKey, - }, - }; -} - -function buildMissingNonInteractiveModelIdMessage(params: { - authChoice: string; - providerLabel: string; - modelPlaceholder: string; -}): string { - return [ - `Missing --custom-model-id for --auth-choice ${params.authChoice}.`, - `Pass the ${params.providerLabel} model id to use, for example ${params.modelPlaceholder}.`, - ].join("\n"); -} - -function buildSelfHostedProviderCredential(params: { - ctx: ProviderAuthMethodNonInteractiveContext; - providerId: string; - resolved: ProviderNonInteractiveApiKeyResult; -}): ApiKeyCredential | null { - return params.ctx.toApiKeyCredential({ - provider: params.providerId, - resolved: params.resolved, - }); -} - -export async function configureOpenAICompatibleSelfHostedProviderNonInteractive(params: { - ctx: ProviderAuthMethodNonInteractiveContext; - providerId: string; - providerLabel: string; - defaultBaseUrl: string; - defaultApiKeyEnvVar: string; - modelPlaceholder: string; - input?: Array<"text" | "image">; - reasoning?: boolean; - contextWindow?: number; - maxTokens?: number; -}): Promise { - const baseUrl = ( - normalizeOptionalSecretInput(params.ctx.opts.customBaseUrl) ?? params.defaultBaseUrl - ).replace(/\/+$/, ""); - const modelId = normalizeOptionalSecretInput(params.ctx.opts.customModelId); - if (!modelId) { - params.ctx.runtime.error( - buildMissingNonInteractiveModelIdMessage({ - authChoice: params.ctx.authChoice, - providerLabel: params.providerLabel, - modelPlaceholder: params.modelPlaceholder, - }), - ); - params.ctx.runtime.exit(1); - return null; - } - - const resolved = await params.ctx.resolveApiKey({ - provider: params.providerId, - flagValue: normalizeOptionalSecretInput(params.ctx.opts.customApiKey), - flagName: "--custom-api-key", - envVar: params.defaultApiKeyEnvVar, - envVarName: params.defaultApiKeyEnvVar, - }); - if (!resolved) { - return null; - } - - const credential = buildSelfHostedProviderCredential({ - ctx: params.ctx, - providerId: params.providerId, - resolved, - }); - if (!credential) { - return null; - } - - const configured = buildOpenAICompatibleSelfHostedProviderConfig({ - cfg: params.ctx.config, - providerId: params.providerId, - baseUrl, - providerApiKey: params.defaultApiKeyEnvVar, - modelId, - input: params.input, - reasoning: params.reasoning, - contextWindow: params.contextWindow, - maxTokens: params.maxTokens, - }); - await upsertAuthProfileWithLock({ - profileId: configured.profileId, - credential, - agentDir: params.ctx.agentDir, - }); - - const withProfile = applyAuthProfileConfig(configured.config, { - profileId: configured.profileId, - provider: params.providerId, - mode: "api_key", - }); - params.ctx.runtime.log(`Default ${params.providerLabel} model: ${modelId}`); - return applyProviderDefaultModel(withProfile, configured.modelRef); -} +export * from "../plugins/provider-self-hosted-setup.js"; diff --git a/src/commands/signal-install.ts b/src/commands/signal-install.ts index a5c73392b4b..0a329ecdde0 100644 --- a/src/commands/signal-install.ts +++ b/src/commands/signal-install.ts @@ -1,302 +1 @@ -import { createWriteStream } from "node:fs"; -import fs from "node:fs/promises"; -import { request } from "node:https"; -import os from "node:os"; -import path from "node:path"; -import { pipeline } from "node:stream/promises"; -import { extractArchive } from "../infra/archive.js"; -import { resolveBrewExecutable } from "../infra/brew.js"; -import { runCommandWithTimeout } from "../process/exec.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { CONFIG_DIR } from "../utils.js"; - -export type ReleaseAsset = { - name?: string; - browser_download_url?: string; -}; - -export type NamedAsset = { - name: string; - browser_download_url: string; -}; - -type ReleaseResponse = { - tag_name?: string; - assets?: ReleaseAsset[]; -}; - -export type SignalInstallResult = { - ok: boolean; - cliPath?: string; - version?: string; - error?: string; -}; - -/** @internal Exported for testing. */ -export async function extractSignalCliArchive( - archivePath: string, - installRoot: string, - timeoutMs: number, -): Promise { - await extractArchive({ archivePath, destDir: installRoot, timeoutMs }); -} - -/** @internal Exported for testing. */ -export function looksLikeArchive(name: string): boolean { - return name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip"); -} - -/** - * Pick a native release asset from the official GitHub releases. - * - * The official signal-cli releases only publish native (GraalVM) binaries for - * x86-64 Linux. On architectures where no native asset is available this - * returns `undefined` so the caller can fall back to a different install - * strategy (e.g. Homebrew). - */ -/** @internal Exported for testing. */ -export function pickAsset( - assets: ReleaseAsset[], - platform: NodeJS.Platform, - arch: string, -): NamedAsset | undefined { - const withName = assets.filter((asset): asset is NamedAsset => - Boolean(asset.name && asset.browser_download_url), - ); - - // Archives only, excluding signature files (.asc) - const archives = withName.filter((a) => looksLikeArchive(a.name.toLowerCase())); - - const byName = (pattern: RegExp) => - archives.find((asset) => pattern.test(asset.name.toLowerCase())); - - if (platform === "linux") { - // The official "Linux-native" asset is an x86-64 GraalVM binary. - // On non-x64 architectures it will fail with "Exec format error", - // so only select it when the host architecture matches. - if (arch === "x64") { - return byName(/linux-native/) || byName(/linux/) || archives[0]; - } - // No native release for this arch — caller should fall back. - return undefined; - } - - if (platform === "darwin") { - return byName(/macos|osx|darwin/) || archives[0]; - } - - if (platform === "win32") { - return byName(/windows|win/) || archives[0]; - } - - return archives[0]; -} - -async function downloadToFile(url: string, dest: string, maxRedirects = 5): Promise { - await new Promise((resolve, reject) => { - const req = request(url, (res) => { - if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) { - const location = res.headers.location; - if (!location || maxRedirects <= 0) { - reject(new Error("Redirect loop or missing Location header")); - return; - } - const redirectUrl = new URL(location, url).href; - resolve(downloadToFile(redirectUrl, dest, maxRedirects - 1)); - return; - } - if (!res.statusCode || res.statusCode >= 400) { - reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading file`)); - return; - } - const out = createWriteStream(dest); - pipeline(res, out).then(resolve).catch(reject); - }); - req.on("error", reject); - req.end(); - }); -} - -async function findSignalCliBinary(root: string): Promise { - const candidates: string[] = []; - const enqueue = async (dir: string, depth: number) => { - if (depth > 3) { - return; - } - const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); - for (const entry of entries) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - await enqueue(full, depth + 1); - } else if (entry.isFile() && entry.name === "signal-cli") { - candidates.push(full); - } - } - }; - await enqueue(root, 0); - return candidates[0] ?? null; -} - -// --------------------------------------------------------------------------- -// Brew-based install (used on architectures without an official native build) -// --------------------------------------------------------------------------- - -async function resolveBrewSignalCliPath(brewExe: string): Promise { - try { - const result = await runCommandWithTimeout([brewExe, "--prefix", "signal-cli"], { - timeoutMs: 10_000, - }); - if (result.code === 0 && result.stdout.trim()) { - const prefix = result.stdout.trim(); - // Homebrew installs the wrapper script at /bin/signal-cli - const candidate = path.join(prefix, "bin", "signal-cli"); - try { - await fs.access(candidate); - return candidate; - } catch { - // Fall back to searching the prefix - return findSignalCliBinary(prefix); - } - } - } catch { - // ignore - } - return null; -} - -async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise { - const brewExe = resolveBrewExecutable(); - if (!brewExe) { - return { - ok: false, - error: - `No native signal-cli build is available for ${process.arch}. ` + - "Install Homebrew (https://brew.sh) and try again, or install signal-cli manually.", - }; - } - - runtime.log(`Installing signal-cli via Homebrew (${brewExe})…`); - const result = await runCommandWithTimeout([brewExe, "install", "signal-cli"], { - timeoutMs: 15 * 60_000, // brew builds from source; can take a while - }); - - if (result.code !== 0) { - return { - ok: false, - error: `brew install signal-cli failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`, - }; - } - - const cliPath = await resolveBrewSignalCliPath(brewExe); - if (!cliPath) { - return { - ok: false, - error: "brew install succeeded but signal-cli binary was not found.", - }; - } - - // Extract version from the installed binary. - let version: string | undefined; - try { - const vResult = await runCommandWithTimeout([cliPath, "--version"], { - timeoutMs: 10_000, - }); - // Output is typically "signal-cli 0.13.24" - version = vResult.stdout.trim().replace(/^signal-cli\s+/, "") || undefined; - } catch { - // non-critical; leave version undefined - } - - return { ok: true, cliPath, version }; -} - -// --------------------------------------------------------------------------- -// Direct download install (used when an official native asset is available) -// --------------------------------------------------------------------------- - -async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise { - const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest"; - const response = await fetch(apiUrl, { - headers: { - "User-Agent": "openclaw", - Accept: "application/vnd.github+json", - }, - }); - - if (!response.ok) { - return { - ok: false, - error: `Failed to fetch release info (${response.status})`, - }; - } - - const payload = (await response.json()) as ReleaseResponse; - const version = payload.tag_name?.replace(/^v/, "") ?? "unknown"; - const assets = payload.assets ?? []; - const asset = pickAsset(assets, process.platform, process.arch); - - if (!asset) { - return { - ok: false, - error: "No compatible release asset found for this platform.", - }; - } - - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-")); - const archivePath = path.join(tmpDir, asset.name); - - runtime.log(`Downloading signal-cli ${version} (${asset.name})…`); - await downloadToFile(asset.browser_download_url, archivePath); - - const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version); - await fs.mkdir(installRoot, { recursive: true }); - - if (!looksLikeArchive(asset.name.toLowerCase())) { - return { ok: false, error: `Unsupported archive type: ${asset.name}` }; - } - try { - await extractSignalCliArchive(archivePath, installRoot, 60_000); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - ok: false, - error: `Failed to extract ${asset.name}: ${message}`, - }; - } - - const cliPath = await findSignalCliBinary(installRoot); - if (!cliPath) { - return { - ok: false, - error: `signal-cli binary not found after extracting ${asset.name}`, - }; - } - - await fs.chmod(cliPath, 0o755).catch(() => {}); - - return { ok: true, cliPath, version }; -} - -// --------------------------------------------------------------------------- -// Public entry point -// --------------------------------------------------------------------------- - -export async function installSignalCli(runtime: RuntimeEnv): Promise { - if (process.platform === "win32") { - return { - ok: false, - error: "Signal CLI auto-install is not supported on Windows yet.", - }; - } - - // The official signal-cli GitHub releases only ship a native binary for - // x86-64 Linux. On other architectures (arm64, armv7, etc.) we delegate - // to Homebrew which builds from source and bundles the JRE automatically. - const hasNativeRelease = process.platform !== "linux" || process.arch === "x64"; - - if (hasNativeRelease) { - return installSignalCliFromRelease(runtime); - } - - return installSignalCliViaBrew(runtime); -} +export * from "../plugins/signal-cli-install.js"; diff --git a/src/commands/vllm-setup.ts b/src/commands/vllm-setup.ts index 4c44587c06e..57d9ce0d3e9 100644 --- a/src/commands/vllm-setup.ts +++ b/src/commands/vllm-setup.ts @@ -1,42 +1 @@ -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, - SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, - SELF_HOSTED_DEFAULT_COST, - SELF_HOSTED_DEFAULT_MAX_TOKENS, - promptAndConfigureOpenAICompatibleSelfHostedProvider, -} from "./self-hosted-provider-setup.js"; - -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; - -export async function promptAndConfigureVllm(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; -}): Promise<{ config: OpenClawConfig; modelId: string; modelRef: string }> { - const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider({ - cfg: params.cfg, - prompter: params.prompter, - providerId: "vllm", - providerLabel: VLLM_PROVIDER_LABEL, - defaultBaseUrl: VLLM_DEFAULT_BASE_URL, - defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR, - modelPlaceholder: VLLM_MODEL_PLACEHOLDER, - }); - return { - config: result.config, - modelId: result.modelId, - modelRef: result.modelRef, - }; -} - -export { applyProviderDefaultModel as applyVllmDefaultModel }; +export * from "../plugins/provider-vllm-setup.js"; diff --git a/src/commands/zai-endpoint-detect.ts b/src/commands/zai-endpoint-detect.ts index 4426b1065fe..a3a53e1f5eb 100644 --- a/src/commands/zai-endpoint-detect.ts +++ b/src/commands/zai-endpoint-detect.ts @@ -1,179 +1 @@ -import { - ZAI_CN_BASE_URL, - ZAI_CODING_CN_BASE_URL, - ZAI_CODING_GLOBAL_BASE_URL, - ZAI_GLOBAL_BASE_URL, -} from "../../extensions/zai/model-definitions.js"; -import { fetchWithTimeout } from "../utils/fetch-timeout.js"; - -export type ZaiEndpointId = "global" | "cn" | "coding-global" | "coding-cn"; - -export type ZaiDetectedEndpoint = { - endpoint: ZaiEndpointId; - /** Provider baseUrl to store in config. */ - baseUrl: string; - /** Recommended default model id for that endpoint. */ - modelId: string; - /** Human-readable note explaining the choice. */ - note: string; -}; - -type ProbeResult = - | { ok: true } - | { - ok: false; - status?: number; - errorCode?: string; - errorMessage?: string; - }; - -async function probeZaiChatCompletions(params: { - baseUrl: string; - apiKey: string; - modelId: string; - timeoutMs: number; - fetchFn?: typeof fetch; -}): Promise { - try { - const res = await fetchWithTimeout( - `${params.baseUrl}/chat/completions`, - { - method: "POST", - headers: { - authorization: `Bearer ${params.apiKey}`, - "content-type": "application/json", - }, - body: JSON.stringify({ - model: params.modelId, - stream: false, - max_tokens: 1, - messages: [{ role: "user", content: "ping" }], - }), - }, - params.timeoutMs, - params.fetchFn, - ); - - if (res.ok) { - return { ok: true }; - } - - let errorCode: string | undefined; - let errorMessage: string | undefined; - try { - const json = (await res.json()) as { - error?: { code?: unknown; message?: unknown }; - msg?: unknown; - message?: unknown; - }; - const code = json?.error?.code; - const msg = json?.error?.message ?? json?.msg ?? json?.message; - if (typeof code === "string") { - errorCode = code; - } else if (typeof code === "number") { - errorCode = String(code); - } - if (typeof msg === "string") { - errorMessage = msg; - } - } catch { - // ignore - } - - return { ok: false, status: res.status, errorCode, errorMessage }; - } catch { - return { ok: false }; - } -} - -export async function detectZaiEndpoint(params: { - apiKey: string; - endpoint?: ZaiEndpointId; - timeoutMs?: number; - fetchFn?: typeof fetch; -}): Promise { - // Never auto-probe in vitest; it would create flaky network behavior. - if (process.env.VITEST && !params.fetchFn) { - return null; - } - - const timeoutMs = params.timeoutMs ?? 5_000; - const probeCandidates = (() => { - const general = [ - { - endpoint: "global" as const, - baseUrl: ZAI_GLOBAL_BASE_URL, - modelId: "glm-5", - note: "Verified GLM-5 on global endpoint.", - }, - { - endpoint: "cn" as const, - baseUrl: ZAI_CN_BASE_URL, - modelId: "glm-5", - note: "Verified GLM-5 on cn endpoint.", - }, - ]; - const codingGlm5 = [ - { - endpoint: "coding-global" as const, - baseUrl: ZAI_CODING_GLOBAL_BASE_URL, - modelId: "glm-5", - note: "Verified GLM-5 on coding-global endpoint.", - }, - { - endpoint: "coding-cn" as const, - baseUrl: ZAI_CODING_CN_BASE_URL, - modelId: "glm-5", - note: "Verified GLM-5 on coding-cn endpoint.", - }, - ]; - const codingFallback = [ - { - endpoint: "coding-global" as const, - baseUrl: ZAI_CODING_GLOBAL_BASE_URL, - modelId: "glm-4.7", - note: "Coding Plan endpoint verified, but this key/plan does not expose GLM-5 there. Defaulting to GLM-4.7.", - }, - { - endpoint: "coding-cn" as const, - baseUrl: ZAI_CODING_CN_BASE_URL, - modelId: "glm-4.7", - note: "Coding Plan CN endpoint verified, but this key/plan does not expose GLM-5 there. Defaulting to GLM-4.7.", - }, - ]; - - switch (params.endpoint) { - case "global": - return general.filter((candidate) => candidate.endpoint === "global"); - case "cn": - return general.filter((candidate) => candidate.endpoint === "cn"); - case "coding-global": - return [ - ...codingGlm5.filter((candidate) => candidate.endpoint === "coding-global"), - ...codingFallback.filter((candidate) => candidate.endpoint === "coding-global"), - ]; - case "coding-cn": - return [ - ...codingGlm5.filter((candidate) => candidate.endpoint === "coding-cn"), - ...codingFallback.filter((candidate) => candidate.endpoint === "coding-cn"), - ]; - default: - return [...general, ...codingGlm5, ...codingFallback]; - } - })(); - - for (const candidate of probeCandidates) { - const result = await probeZaiChatCompletions({ - baseUrl: candidate.baseUrl, - apiKey: params.apiKey, - modelId: candidate.modelId, - timeoutMs, - fetchFn: params.fetchFn, - }); - if (result.ok) { - return candidate; - } - } - - return null; -} +export * from "../plugins/provider-zai-endpoint.js"; diff --git a/src/plugin-sdk-internal/setup.ts b/src/plugin-sdk-internal/setup.ts index d012e201bd8..f6643637e7e 100644 --- a/src/plugin-sdk-internal/setup.ts +++ b/src/plugin-sdk-internal/setup.ts @@ -30,6 +30,8 @@ export { setSetupChannelEnabled, splitSetupEntries, } from "../channels/plugins/setup-wizard-helpers.js"; +export { detectBinary } from "../plugins/setup-binary.js"; +export { installSignalCli } from "../plugins/signal-cli-install.js"; export { formatCliCommand } from "../cli/command-format.js"; export { formatDocsLink } from "../terminal/links.js"; export { hasConfiguredSecretInput } from "../config/types.secrets.js"; diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index 4eddbd51a29..d9a704df27e 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -24,5 +24,6 @@ export * from "../agents/tools/web-shared.js"; export * from "../agents/tools/discord-actions-moderation-shared.js"; export * from "../agents/tools/web-fetch-utils.js"; export * from "../agents/vllm-defaults.js"; +// Intentional public runtime surface: channel plugins use ingress agent helpers directly. export * from "../commands/agent.js"; export * from "../tts/tts.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index c5ba9d90541..50949a31a89 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -425,8 +425,8 @@ export { resolveRuntimeEnv, resolveRuntimeEnvWithUnavailableExit, } from "./runtime.js"; -export { detectBinary } from "../commands/onboard-helpers.js"; -export { installSignalCli } from "../commands/signal-install.js"; +export { detectBinary } from "../plugins/setup-binary.js"; +export { installSignalCli } from "../plugins/signal-cli-install.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { resolveTextChunkLimit } from "../auto-reply/chunk.js"; export { readBooleanParam } from "./boolean-param.js"; @@ -798,21 +798,21 @@ export { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_COST, SELF_HOSTED_DEFAULT_MAX_TOKENS, -} from "../commands/self-hosted-provider-setup.ts"; +} from "../plugins/provider-self-hosted-setup.js"; export { OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL, configureOllamaNonInteractive, ensureOllamaModelPulled, promptAndConfigureOllama, -} from "../commands/ollama-setup.ts"; +} from "../plugins/provider-ollama-setup.js"; export { VLLM_DEFAULT_BASE_URL, VLLM_DEFAULT_CONTEXT_WINDOW, VLLM_DEFAULT_COST, VLLM_DEFAULT_MAX_TOKENS, promptAndConfigureVllm, -} from "../commands/vllm-setup.ts"; +} from "../plugins/provider-vllm-setup.js"; export { buildOllamaProvider, buildSglangProvider, diff --git a/src/plugin-sdk/ollama-setup.ts b/src/plugin-sdk/ollama-setup.ts index fa8c9032dda..2ddad898bb7 100644 --- a/src/plugin-sdk/ollama-setup.ts +++ b/src/plugin-sdk/ollama-setup.ts @@ -12,6 +12,6 @@ export { configureOllamaNonInteractive, ensureOllamaModelPulled, promptAndConfigureOllama, -} from "../commands/ollama-setup.ts"; +} from "../plugins/provider-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 index 4489c8ae34d..57f1a94e3bd 100644 --- a/src/plugin-sdk/provider-setup.ts +++ b/src/plugin-sdk/provider-setup.ts @@ -15,21 +15,21 @@ export { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_COST, SELF_HOSTED_DEFAULT_MAX_TOKENS, -} from "../commands/self-hosted-provider-setup.ts"; +} from "../plugins/provider-self-hosted-setup.js"; export { OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL, configureOllamaNonInteractive, ensureOllamaModelPulled, promptAndConfigureOllama, -} from "../commands/ollama-setup.ts"; +} from "../plugins/provider-ollama-setup.js"; export { VLLM_DEFAULT_BASE_URL, VLLM_DEFAULT_CONTEXT_WINDOW, VLLM_DEFAULT_COST, VLLM_DEFAULT_MAX_TOKENS, promptAndConfigureVllm, -} from "../commands/vllm-setup.ts"; +} from "../plugins/provider-vllm-setup.js"; export { buildOllamaProvider, buildSglangProvider, diff --git a/src/plugin-sdk/self-hosted-provider-setup.ts b/src/plugin-sdk/self-hosted-provider-setup.ts index 60be2852a2d..47fe7d6588f 100644 --- a/src/plugin-sdk/self-hosted-provider-setup.ts +++ b/src/plugin-sdk/self-hosted-provider-setup.ts @@ -15,7 +15,7 @@ export { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_COST, SELF_HOSTED_DEFAULT_MAX_TOKENS, -} from "../commands/self-hosted-provider-setup.ts"; +} from "../plugins/provider-self-hosted-setup.js"; export { buildSglangProvider, diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts index b890045a5f8..61785569d07 100644 --- a/src/plugin-sdk/setup.ts +++ b/src/plugin-sdk/setup.ts @@ -12,8 +12,8 @@ export type { ChannelSetupWizard } from "../channels/plugins/setup-wizard.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { formatCliCommand } from "../cli/command-format.js"; -export { detectBinary } from "../commands/onboard-helpers.js"; -export { installSignalCli } from "../commands/signal-install.js"; +export { detectBinary } from "../plugins/setup-binary.js"; +export { installSignalCli } from "../plugins/signal-cli-install.js"; export { formatDocsLink } from "../terminal/links.js"; export { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; export { normalizeE164, pathExists } from "../utils.js"; diff --git a/src/plugin-sdk/zai.ts b/src/plugin-sdk/zai.ts index 6981a0994bf..87a745ee7d0 100644 --- a/src/plugin-sdk/zai.ts +++ b/src/plugin-sdk/zai.ts @@ -4,4 +4,4 @@ export { detectZaiEndpoint, type ZaiDetectedEndpoint, type ZaiEndpointId, -} from "../commands/zai-endpoint-detect.js"; +} from "../plugins/provider-zai-endpoint.js"; diff --git a/src/plugins/contracts/auth-choice.contract.test.ts b/src/plugins/contracts/auth-choice.contract.test.ts index f6af2bed48e..b33ef2740e8 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, 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 type { AuthChoice } from "../../commands/onboard-types.js"; import { createAuthTestLifecycle, createExitThrowingRuntime, @@ -129,7 +128,7 @@ describe("provider auth-choice contract", () => { { authChoice: "minimax-global-oauth" as const, expectedProvider: "minimax-portal" }, { authChoice: "modelstudio-api-key" as const, expectedProvider: "modelstudio" }, { authChoice: "ollama" as const, expectedProvider: "ollama" }, - { authChoice: "unknown" as AuthChoice, expectedProvider: undefined }, + { authChoice: "unknown", expectedProvider: undefined }, ] as const; for (const scenario of scenarios) { diff --git a/src/plugins/provider-ollama-setup.ts b/src/plugins/provider-ollama-setup.ts new file mode 100644 index 00000000000..ac3fd5d1fc7 --- /dev/null +++ b/src/plugins/provider-ollama-setup.ts @@ -0,0 +1,535 @@ +import { upsertAuthProfileWithLock } from "../agents/auth-profiles/upsert-with-lock.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; +import { + buildOllamaModelDefinition, + enrichOllamaModelsWithContext, + fetchOllamaModels, + resolveOllamaApiBase, + type OllamaModelWithContext, +} from "../agents/ollama-models.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { WizardCancelledError, type WizardPrompter } from "../wizard/prompts.js"; +import { applyAgentDefaultModelPrimary } from "./provider-onboarding-config.js"; +import { isRemoteEnvironment, openUrl } from "./setup-browser.js"; +import type { ProviderAuthOptionBag } from "./types.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"]; +const OLLAMA_SUGGESTED_MODELS_CLOUD = ["kimi-k2.5:cloud", "minimax-m2.5:cloud", "glm-5:cloud"]; +type OllamaMode = "remote" | "local"; +type OllamaSetupOptions = ProviderAuthOptionBag & { + customBaseUrl?: string; + customModelId?: string; +}; + +function normalizeOllamaModelName(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed.toLowerCase().startsWith("ollama/")) { + const withoutPrefix = trimmed.slice("ollama/".length).trim(); + return withoutPrefix || undefined; + } + return trimmed; +} + +function isOllamaCloudModel(modelName: string | undefined): boolean { + return Boolean(modelName?.trim().toLowerCase().endsWith(":cloud")); +} + +function formatOllamaPullStatus(status: string): { text: string; hidePercent: boolean } { + const trimmed = status.trim(); + const partStatusMatch = trimmed.match(/^([a-z-]+)\s+(?:sha256:)?[a-f0-9]{8,}$/i); + if (partStatusMatch) { + return { text: `${partStatusMatch[1]} part`, hidePercent: false }; + } + if (/^verifying\b.*\bdigest\b/i.test(trimmed)) { + return { text: "verifying digest", hidePercent: true }; + } + return { text: trimmed, hidePercent: false }; +} + +type OllamaCloudAuthResult = { + signedIn: boolean; + signinUrl?: string; +}; + +/** Check if the user is signed in to Ollama cloud via /api/me. */ +async function checkOllamaCloudAuth(baseUrl: string): Promise { + try { + const apiBase = resolveOllamaApiBase(baseUrl); + const response = await fetch(`${apiBase}/api/me`, { + method: "POST", + signal: AbortSignal.timeout(5000), + }); + if (response.status === 401) { + // 401 body contains { error, signin_url } + const data = (await response.json()) as { signin_url?: string }; + return { signedIn: false, signinUrl: data.signin_url }; + } + if (!response.ok) { + return { signedIn: false }; + } + return { signedIn: true }; + } catch { + // /api/me not supported or unreachable — fail closed so cloud mode + // doesn't silently skip auth; the caller handles the fallback. + return { signedIn: false }; + } +} + +type OllamaPullChunk = { + status?: string; + total?: number; + completed?: number; + error?: string; +}; + +type OllamaPullFailureKind = "http" | "no-body" | "chunk-error" | "network"; +type OllamaPullResult = + | { ok: true } + | { + ok: false; + kind: OllamaPullFailureKind; + message: string; + }; + +async function pullOllamaModelCore(params: { + baseUrl: string; + modelName: string; + onStatus?: (status: string, percent: number | null) => void; +}): Promise { + const { onStatus } = params; + const baseUrl = resolveOllamaApiBase(params.baseUrl); + const modelName = normalizeOllamaModelName(params.modelName) ?? params.modelName.trim(); + try { + const response = await fetch(`${baseUrl}/api/pull`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: modelName }), + }); + if (!response.ok) { + return { + ok: false, + kind: "http", + message: `Failed to download ${modelName} (HTTP ${response.status})`, + }; + } + if (!response.body) { + return { + ok: false, + kind: "no-body", + message: `Failed to download ${modelName} (no response body)`, + }; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const layers = new Map(); + + const parseLine = (line: string): OllamaPullResult => { + const trimmed = line.trim(); + if (!trimmed) { + return { ok: true }; + } + try { + const chunk = JSON.parse(trimmed) as OllamaPullChunk; + if (chunk.error) { + return { + ok: false, + kind: "chunk-error", + message: `Download failed: ${chunk.error}`, + }; + } + if (!chunk.status) { + return { ok: true }; + } + if (chunk.total && chunk.completed !== undefined) { + layers.set(chunk.status, { total: chunk.total, completed: chunk.completed }); + let totalSum = 0; + let completedSum = 0; + for (const layer of layers.values()) { + totalSum += layer.total; + completedSum += layer.completed; + } + const percent = totalSum > 0 ? Math.round((completedSum / totalSum) * 100) : null; + onStatus?.(chunk.status, percent); + } else { + onStatus?.(chunk.status, null); + } + } catch { + // Ignore malformed lines from streaming output. + } + return { ok: true }; + }; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const parsed = parseLine(line); + if (!parsed.ok) { + return parsed; + } + } + } + + const trailing = buffer.trim(); + if (trailing) { + const parsed = parseLine(trailing); + if (!parsed.ok) { + return parsed; + } + } + + return { ok: true }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + return { + ok: false, + kind: "network", + message: `Failed to download ${modelName}: ${reason}`, + }; + } +} + +/** Pull a model from Ollama, streaming progress updates. */ +async function pullOllamaModel( + baseUrl: string, + modelName: string, + prompter: WizardPrompter, +): Promise { + const spinner = prompter.progress(`Downloading ${modelName}...`); + const result = await pullOllamaModelCore({ + baseUrl, + modelName, + onStatus: (status, percent) => { + const displayStatus = formatOllamaPullStatus(status); + if (displayStatus.hidePercent) { + spinner.update(`Downloading ${modelName} - ${displayStatus.text}`); + } else { + spinner.update(`Downloading ${modelName} - ${displayStatus.text} - ${percent ?? 0}%`); + } + }, + }); + if (!result.ok) { + spinner.stop(result.message); + return false; + } + spinner.stop(`Downloaded ${modelName}`); + return true; +} + +async function pullOllamaModelNonInteractive( + baseUrl: string, + modelName: string, + runtime: RuntimeEnv, +): Promise { + runtime.log(`Downloading ${modelName}...`); + const result = await pullOllamaModelCore({ baseUrl, modelName }); + if (!result.ok) { + runtime.error(result.message); + return false; + } + runtime.log(`Downloaded ${modelName}`); + return true; +} + +function buildOllamaModelsConfig( + modelNames: string[], + discoveredModelsByName?: Map, +) { + return modelNames.map((name) => + buildOllamaModelDefinition(name, discoveredModelsByName?.get(name)?.contextWindow), + ); +} + +function applyOllamaProviderConfig( + cfg: OpenClawConfig, + baseUrl: string, + modelNames: string[], + discoveredModelsByName?: Map, +): OpenClawConfig { + return { + ...cfg, + models: { + ...cfg.models, + mode: cfg.models?.mode ?? "merge", + providers: { + ...cfg.models?.providers, + ollama: { + baseUrl, + api: "ollama", + apiKey: "OLLAMA_API_KEY", // pragma: allowlist secret + models: buildOllamaModelsConfig(modelNames, discoveredModelsByName), + }, + }, + }, + }; +} + +async function storeOllamaCredential(agentDir?: string): Promise { + await upsertAuthProfileWithLock({ + profileId: "ollama:default", + credential: { type: "api_key", provider: "ollama", key: "ollama-local" }, + agentDir, + }); +} + +/** + * Interactive: prompt for base URL, discover models, configure provider. + * Model selection is handled by the standard model picker downstream. + */ +export async function promptAndConfigureOllama(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; +}): Promise<{ config: OpenClawConfig; defaultModelId: string }> { + const { prompter } = params; + + // 1. Prompt base URL + const baseUrlRaw = await prompter.text({ + message: "Ollama base URL", + initialValue: OLLAMA_DEFAULT_BASE_URL, + placeholder: OLLAMA_DEFAULT_BASE_URL, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const configuredBaseUrl = String(baseUrlRaw ?? "") + .trim() + .replace(/\/+$/, ""); + const baseUrl = resolveOllamaApiBase(configuredBaseUrl); + + // 2. Check reachability + const { reachable, models } = await fetchOllamaModels(baseUrl); + + if (!reachable) { + await prompter.note( + [ + `Ollama could not be reached at ${baseUrl}.`, + "Download it at https://ollama.com/download", + "", + "Start Ollama and re-run setup.", + ].join("\n"), + "Ollama", + ); + throw new WizardCancelledError("Ollama not reachable"); + } + + const enrichedModels = await enrichOllamaModelsWithContext(baseUrl, models.slice(0, 50)); + const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model])); + const modelNames = models.map((m) => m.name); + + // 3. Mode selection + const mode = (await prompter.select({ + message: "Ollama mode", + options: [ + { value: "remote", label: "Cloud + Local", hint: "Ollama cloud models + local models" }, + { value: "local", label: "Local", hint: "Local models only" }, + ], + })) as OllamaMode; + + // 4. Cloud auth — check /api/me upfront for remote (cloud+local) mode + let cloudAuthVerified = false; + if (mode === "remote") { + const authResult = await checkOllamaCloudAuth(baseUrl); + if (!authResult.signedIn) { + if (authResult.signinUrl) { + if (!isRemoteEnvironment()) { + await openUrl(authResult.signinUrl); + } + await prompter.note( + ["Sign in to Ollama Cloud:", authResult.signinUrl].join("\n"), + "Ollama Cloud", + ); + const confirmed = await prompter.confirm({ + message: "Have you signed in?", + }); + if (!confirmed) { + throw new WizardCancelledError("Ollama cloud sign-in cancelled"); + } + // Re-check after user claims sign-in + const recheck = await checkOllamaCloudAuth(baseUrl); + if (!recheck.signedIn) { + throw new WizardCancelledError("Ollama cloud sign-in required"); + } + cloudAuthVerified = true; + } else { + // No signin URL available (older server, unreachable /api/me, or custom gateway). + await prompter.note( + [ + "Could not verify Ollama Cloud authentication.", + "Cloud models may not work until you sign in at https://ollama.com.", + ].join("\n"), + "Ollama Cloud", + ); + const continueAnyway = await prompter.confirm({ + message: "Continue without cloud auth?", + }); + if (!continueAnyway) { + throw new WizardCancelledError("Ollama cloud auth could not be verified"); + } + // Cloud auth unverified — fall back to local defaults so the model + // picker doesn't steer toward cloud models that may fail. + } + } else { + cloudAuthVerified = true; + } + } + + // 5. Model ordering — suggested models first. + // Use cloud defaults only when auth was actually verified; otherwise fall + // back to local defaults so the user isn't steered toward cloud models + // that may fail at runtime. + const suggestedModels = + mode === "local" || !cloudAuthVerified + ? OLLAMA_SUGGESTED_MODELS_LOCAL + : OLLAMA_SUGGESTED_MODELS_CLOUD; + const orderedModelNames = [ + ...suggestedModels, + ...modelNames.filter((name) => !suggestedModels.includes(name)), + ]; + + const defaultModelId = suggestedModels[0] ?? OLLAMA_DEFAULT_MODEL; + const config = applyOllamaProviderConfig( + params.cfg, + baseUrl, + orderedModelNames, + discoveredModelsByName, + ); + return { config, defaultModelId }; +} + +/** Non-interactive: auto-discover models and configure provider. */ +export async function configureOllamaNonInteractive(params: { + nextConfig: OpenClawConfig; + opts: OllamaSetupOptions; + runtime: RuntimeEnv; +}): Promise { + const { opts, runtime } = params; + const configuredBaseUrl = (opts.customBaseUrl?.trim() || OLLAMA_DEFAULT_BASE_URL).replace( + /\/+$/, + "", + ); + const baseUrl = resolveOllamaApiBase(configuredBaseUrl); + + const { reachable, models } = await fetchOllamaModels(baseUrl); + const explicitModel = normalizeOllamaModelName(opts.customModelId); + + if (!reachable) { + runtime.error( + [ + `Ollama could not be reached at ${baseUrl}.`, + "Download it at https://ollama.com/download", + ].join("\n"), + ); + runtime.exit(1); + return params.nextConfig; + } + + await storeOllamaCredential(); + + const enrichedModels = await enrichOllamaModelsWithContext(baseUrl, models.slice(0, 50)); + const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model])); + const modelNames = models.map((m) => m.name); + + // Apply local suggested model ordering. + const suggestedModels = OLLAMA_SUGGESTED_MODELS_LOCAL; + const orderedModelNames = [ + ...suggestedModels, + ...modelNames.filter((name) => !suggestedModels.includes(name)), + ]; + + const requestedDefaultModelId = explicitModel ?? suggestedModels[0]; + let pulledRequestedModel = false; + const availableModelNames = new Set(modelNames); + const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId); + + if (requestedCloudModel) { + availableModelNames.add(requestedDefaultModelId); + } + + // Pull if model not in discovered list and Ollama is reachable + if (!requestedCloudModel && !modelNames.includes(requestedDefaultModelId)) { + pulledRequestedModel = await pullOllamaModelNonInteractive( + baseUrl, + requestedDefaultModelId, + runtime, + ); + if (pulledRequestedModel) { + availableModelNames.add(requestedDefaultModelId); + } + } + + let allModelNames = orderedModelNames; + let defaultModelId = requestedDefaultModelId; + if ( + (pulledRequestedModel || requestedCloudModel) && + !allModelNames.includes(requestedDefaultModelId) + ) { + allModelNames = [...allModelNames, requestedDefaultModelId]; + } + if (!availableModelNames.has(requestedDefaultModelId)) { + if (availableModelNames.size > 0) { + const firstAvailableModel = + allModelNames.find((name) => availableModelNames.has(name)) ?? + Array.from(availableModelNames)[0]; + defaultModelId = firstAvailableModel; + runtime.log( + `Ollama model ${requestedDefaultModelId} was not available; using ${defaultModelId} instead.`, + ); + } else { + runtime.error( + [ + `No Ollama models are available at ${baseUrl}.`, + "Pull a model first, then re-run setup.", + ].join("\n"), + ); + runtime.exit(1); + return params.nextConfig; + } + } + + const config = applyOllamaProviderConfig( + params.nextConfig, + baseUrl, + allModelNames, + discoveredModelsByName, + ); + const modelRef = `ollama/${defaultModelId}`; + runtime.log(`Default Ollama model: ${defaultModelId}`); + return applyAgentDefaultModelPrimary(config, modelRef); +} + +/** Pull the configured default Ollama model if it isn't already available locally. */ +export async function ensureOllamaModelPulled(params: { + config: OpenClawConfig; + prompter: WizardPrompter; +}): Promise { + const modelCfg = params.config.agents?.defaults?.model; + const modelId = typeof modelCfg === "string" ? modelCfg : modelCfg?.primary; + if (!modelId?.startsWith("ollama/")) { + return; + } + const baseUrl = params.config.models?.providers?.ollama?.baseUrl ?? OLLAMA_DEFAULT_BASE_URL; + const modelName = modelId.slice("ollama/".length); + if (isOllamaCloudModel(modelName)) { + return; + } + const { models } = await fetchOllamaModels(baseUrl); + if (models.some((m) => m.name === modelName)) { + return; + } + const pulled = await pullOllamaModel(baseUrl, modelName, params.prompter); + if (!pulled) { + throw new WizardCancelledError("Failed to download selected Ollama model"); + } +} diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts new file mode 100644 index 00000000000..db7223ed987 --- /dev/null +++ b/src/plugins/provider-self-hosted-setup.ts @@ -0,0 +1,304 @@ +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 { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { applyAuthProfileConfig } from "./provider-auth-helpers.js"; +import type { + ProviderDiscoveryContext, + ProviderAuthResult, + ProviderAuthMethodNonInteractiveContext, + ProviderNonInteractiveApiKeyResult, +} from "./types.js"; + +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; + const fallbacks = + existingModel && typeof existingModel === "object" && "fallbacks" in existingModel + ? (existingModel as { fallbacks?: string[] }).fallbacks + : undefined; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { + ...(fallbacks ? { fallbacks } : undefined), + primary: modelRef, + }, + }, + }, + }; +} + +function buildOpenAICompatibleSelfHostedProviderConfig(params: { + cfg: OpenClawConfig; + providerId: string; + baseUrl: string; + providerApiKey: string; + modelId: string; + input?: Array<"text" | "image">; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; +}): { config: OpenClawConfig; modelId: string; modelRef: string; profileId: string } { + const modelRef = `${params.providerId}/${params.modelId}`; + const profileId = `${params.providerId}:default`; + return { + config: { + ...params.cfg, + models: { + ...params.cfg.models, + mode: params.cfg.models?.mode ?? "merge", + providers: { + ...params.cfg.models?.providers, + [params.providerId]: { + baseUrl: params.baseUrl, + api: "openai-completions", + apiKey: params.providerApiKey, + models: [ + { + id: params.modelId, + name: params.modelId, + reasoning: params.reasoning ?? false, + input: params.input ?? ["text"], + cost: SELF_HOSTED_DEFAULT_COST, + contextWindow: params.contextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS, + }, + ], + }, + }, + }, + }, + modelId: params.modelId, + modelRef, + profileId, + }; +} + +type OpenAICompatibleSelfHostedProviderSetupParams = { + cfg: OpenClawConfig; + prompter: WizardPrompter; + providerId: string; + providerLabel: string; + defaultBaseUrl: string; + defaultApiKeyEnvVar: string; + modelPlaceholder: string; + input?: Array<"text" | "image">; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; +}; + +type OpenAICompatibleSelfHostedProviderPromptResult = { + config: OpenClawConfig; + credential: AuthProfileCredential; + modelId: string; + modelRef: string; + profileId: string; +}; + +function buildSelfHostedProviderAuthResult( + result: OpenAICompatibleSelfHostedProviderPromptResult, +): ProviderAuthResult { + return { + profiles: [ + { + profileId: result.profileId, + credential: result.credential, + }, + ], + configPatch: result.config, + defaultModel: result.modelRef, + }; +} + +export async function promptAndConfigureOpenAICompatibleSelfHostedProvider( + params: OpenAICompatibleSelfHostedProviderSetupParams, +): Promise { + const baseUrlRaw = await params.prompter.text({ + message: `${params.providerLabel} base URL`, + initialValue: params.defaultBaseUrl, + placeholder: params.defaultBaseUrl, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const apiKeyRaw = await params.prompter.text({ + message: `${params.providerLabel} API key`, + placeholder: "sk-... (or any non-empty string)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const modelIdRaw = await params.prompter.text({ + message: `${params.providerLabel} model`, + placeholder: params.modelPlaceholder, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + + const baseUrl = String(baseUrlRaw ?? "") + .trim() + .replace(/\/+$/, ""); + const apiKey = String(apiKeyRaw ?? "").trim(); + const modelId = String(modelIdRaw ?? "").trim(); + const credential: AuthProfileCredential = { + type: "api_key", + provider: params.providerId, + key: apiKey, + }; + const configured = buildOpenAICompatibleSelfHostedProviderConfig({ + cfg: params.cfg, + providerId: params.providerId, + baseUrl, + providerApiKey: params.defaultApiKeyEnvVar, + modelId, + input: params.input, + reasoning: params.reasoning, + contextWindow: params.contextWindow, + maxTokens: params.maxTokens, + }); + + return { + config: configured.config, + credential, + modelId: configured.modelId, + modelRef: configured.modelRef, + profileId: configured.profileId, + }; +} + +export async function promptAndConfigureOpenAICompatibleSelfHostedProviderAuth( + params: OpenAICompatibleSelfHostedProviderSetupParams, +): Promise { + const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider(params); + return buildSelfHostedProviderAuthResult(result); +} + +export async function discoverOpenAICompatibleSelfHostedProvider< + T extends Record, +>(params: { + ctx: ProviderDiscoveryContext; + providerId: string; + buildProvider: (params: { apiKey?: string }) => Promise; +}): Promise<{ provider: T & { apiKey: string } } | null> { + if (params.ctx.config.models?.providers?.[params.providerId]) { + return null; + } + const { apiKey, discoveryApiKey } = params.ctx.resolveProviderApiKey(params.providerId); + if (!apiKey) { + return null; + } + return { + provider: { + ...(await params.buildProvider({ apiKey: discoveryApiKey })), + apiKey, + }, + }; +} + +function buildMissingNonInteractiveModelIdMessage(params: { + authChoice: string; + providerLabel: string; + modelPlaceholder: string; +}): string { + return [ + `Missing --custom-model-id for --auth-choice ${params.authChoice}.`, + `Pass the ${params.providerLabel} model id to use, for example ${params.modelPlaceholder}.`, + ].join("\n"); +} + +function buildSelfHostedProviderCredential(params: { + ctx: ProviderAuthMethodNonInteractiveContext; + providerId: string; + resolved: ProviderNonInteractiveApiKeyResult; +}): ApiKeyCredential | null { + return params.ctx.toApiKeyCredential({ + provider: params.providerId, + resolved: params.resolved, + }); +} + +export async function configureOpenAICompatibleSelfHostedProviderNonInteractive(params: { + ctx: ProviderAuthMethodNonInteractiveContext; + providerId: string; + providerLabel: string; + defaultBaseUrl: string; + defaultApiKeyEnvVar: string; + modelPlaceholder: string; + input?: Array<"text" | "image">; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; +}): Promise { + const baseUrl = ( + normalizeOptionalSecretInput(params.ctx.opts.customBaseUrl) ?? params.defaultBaseUrl + ).replace(/\/+$/, ""); + const modelId = normalizeOptionalSecretInput(params.ctx.opts.customModelId); + if (!modelId) { + params.ctx.runtime.error( + buildMissingNonInteractiveModelIdMessage({ + authChoice: params.ctx.authChoice, + providerLabel: params.providerLabel, + modelPlaceholder: params.modelPlaceholder, + }), + ); + params.ctx.runtime.exit(1); + return null; + } + + const resolved = await params.ctx.resolveApiKey({ + provider: params.providerId, + flagValue: normalizeOptionalSecretInput(params.ctx.opts.customApiKey), + flagName: "--custom-api-key", + envVar: params.defaultApiKeyEnvVar, + envVarName: params.defaultApiKeyEnvVar, + }); + if (!resolved) { + return null; + } + + const credential = buildSelfHostedProviderCredential({ + ctx: params.ctx, + providerId: params.providerId, + resolved, + }); + if (!credential) { + return null; + } + + const configured = buildOpenAICompatibleSelfHostedProviderConfig({ + cfg: params.ctx.config, + providerId: params.providerId, + baseUrl, + providerApiKey: params.defaultApiKeyEnvVar, + modelId, + input: params.input, + reasoning: params.reasoning, + contextWindow: params.contextWindow, + maxTokens: params.maxTokens, + }); + await upsertAuthProfileWithLock({ + profileId: configured.profileId, + credential, + agentDir: params.ctx.agentDir, + }); + + const withProfile = applyAuthProfileConfig(configured.config, { + profileId: configured.profileId, + provider: params.providerId, + mode: "api_key", + }); + params.ctx.runtime.log(`Default ${params.providerLabel} model: ${modelId}`); + return applyProviderDefaultModel(withProfile, configured.modelRef); +} diff --git a/src/plugins/provider-vllm-setup.ts b/src/plugins/provider-vllm-setup.ts new file mode 100644 index 00000000000..01f291abbe5 --- /dev/null +++ b/src/plugins/provider-vllm-setup.ts @@ -0,0 +1,42 @@ +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, + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, + promptAndConfigureOpenAICompatibleSelfHostedProvider, +} from "./provider-self-hosted-setup.js"; + +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; + +export async function promptAndConfigureVllm(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; +}): Promise<{ config: OpenClawConfig; modelId: string; modelRef: string }> { + const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider({ + cfg: params.cfg, + prompter: params.prompter, + providerId: "vllm", + providerLabel: VLLM_PROVIDER_LABEL, + defaultBaseUrl: VLLM_DEFAULT_BASE_URL, + defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: VLLM_MODEL_PLACEHOLDER, + }); + return { + config: result.config, + modelId: result.modelId, + modelRef: result.modelRef, + }; +} + +export { applyProviderDefaultModel as applyVllmDefaultModel }; diff --git a/src/plugins/provider-zai-endpoint.ts b/src/plugins/provider-zai-endpoint.ts new file mode 100644 index 00000000000..4426b1065fe --- /dev/null +++ b/src/plugins/provider-zai-endpoint.ts @@ -0,0 +1,179 @@ +import { + ZAI_CN_BASE_URL, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_GLOBAL_BASE_URL, + ZAI_GLOBAL_BASE_URL, +} from "../../extensions/zai/model-definitions.js"; +import { fetchWithTimeout } from "../utils/fetch-timeout.js"; + +export type ZaiEndpointId = "global" | "cn" | "coding-global" | "coding-cn"; + +export type ZaiDetectedEndpoint = { + endpoint: ZaiEndpointId; + /** Provider baseUrl to store in config. */ + baseUrl: string; + /** Recommended default model id for that endpoint. */ + modelId: string; + /** Human-readable note explaining the choice. */ + note: string; +}; + +type ProbeResult = + | { ok: true } + | { + ok: false; + status?: number; + errorCode?: string; + errorMessage?: string; + }; + +async function probeZaiChatCompletions(params: { + baseUrl: string; + apiKey: string; + modelId: string; + timeoutMs: number; + fetchFn?: typeof fetch; +}): Promise { + try { + const res = await fetchWithTimeout( + `${params.baseUrl}/chat/completions`, + { + method: "POST", + headers: { + authorization: `Bearer ${params.apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: params.modelId, + stream: false, + max_tokens: 1, + messages: [{ role: "user", content: "ping" }], + }), + }, + params.timeoutMs, + params.fetchFn, + ); + + if (res.ok) { + return { ok: true }; + } + + let errorCode: string | undefined; + let errorMessage: string | undefined; + try { + const json = (await res.json()) as { + error?: { code?: unknown; message?: unknown }; + msg?: unknown; + message?: unknown; + }; + const code = json?.error?.code; + const msg = json?.error?.message ?? json?.msg ?? json?.message; + if (typeof code === "string") { + errorCode = code; + } else if (typeof code === "number") { + errorCode = String(code); + } + if (typeof msg === "string") { + errorMessage = msg; + } + } catch { + // ignore + } + + return { ok: false, status: res.status, errorCode, errorMessage }; + } catch { + return { ok: false }; + } +} + +export async function detectZaiEndpoint(params: { + apiKey: string; + endpoint?: ZaiEndpointId; + timeoutMs?: number; + fetchFn?: typeof fetch; +}): Promise { + // Never auto-probe in vitest; it would create flaky network behavior. + if (process.env.VITEST && !params.fetchFn) { + return null; + } + + const timeoutMs = params.timeoutMs ?? 5_000; + const probeCandidates = (() => { + const general = [ + { + endpoint: "global" as const, + baseUrl: ZAI_GLOBAL_BASE_URL, + modelId: "glm-5", + note: "Verified GLM-5 on global endpoint.", + }, + { + endpoint: "cn" as const, + baseUrl: ZAI_CN_BASE_URL, + modelId: "glm-5", + note: "Verified GLM-5 on cn endpoint.", + }, + ]; + const codingGlm5 = [ + { + endpoint: "coding-global" as const, + baseUrl: ZAI_CODING_GLOBAL_BASE_URL, + modelId: "glm-5", + note: "Verified GLM-5 on coding-global endpoint.", + }, + { + endpoint: "coding-cn" as const, + baseUrl: ZAI_CODING_CN_BASE_URL, + modelId: "glm-5", + note: "Verified GLM-5 on coding-cn endpoint.", + }, + ]; + const codingFallback = [ + { + endpoint: "coding-global" as const, + baseUrl: ZAI_CODING_GLOBAL_BASE_URL, + modelId: "glm-4.7", + note: "Coding Plan endpoint verified, but this key/plan does not expose GLM-5 there. Defaulting to GLM-4.7.", + }, + { + endpoint: "coding-cn" as const, + baseUrl: ZAI_CODING_CN_BASE_URL, + modelId: "glm-4.7", + note: "Coding Plan CN endpoint verified, but this key/plan does not expose GLM-5 there. Defaulting to GLM-4.7.", + }, + ]; + + switch (params.endpoint) { + case "global": + return general.filter((candidate) => candidate.endpoint === "global"); + case "cn": + return general.filter((candidate) => candidate.endpoint === "cn"); + case "coding-global": + return [ + ...codingGlm5.filter((candidate) => candidate.endpoint === "coding-global"), + ...codingFallback.filter((candidate) => candidate.endpoint === "coding-global"), + ]; + case "coding-cn": + return [ + ...codingGlm5.filter((candidate) => candidate.endpoint === "coding-cn"), + ...codingFallback.filter((candidate) => candidate.endpoint === "coding-cn"), + ]; + default: + return [...general, ...codingGlm5, ...codingFallback]; + } + })(); + + for (const candidate of probeCandidates) { + const result = await probeZaiChatCompletions({ + baseUrl: candidate.baseUrl, + apiKey: params.apiKey, + modelId: candidate.modelId, + timeoutMs, + fetchFn: params.fetchFn, + }); + if (result.ok) { + return candidate; + } + } + + return null; +} diff --git a/src/plugins/setup-binary.ts b/src/plugins/setup-binary.ts new file mode 100644 index 00000000000..c1e534c2944 --- /dev/null +++ b/src/plugins/setup-binary.ts @@ -0,0 +1,36 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { isSafeExecutableValue } from "../infra/exec-safety.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { resolveUserPath } from "../utils.js"; + +export async function detectBinary(name: string): Promise { + if (!name?.trim()) { + return false; + } + if (!isSafeExecutableValue(name)) { + return false; + } + const resolved = name.startsWith("~") ? resolveUserPath(name) : name; + if ( + path.isAbsolute(resolved) || + resolved.startsWith(".") || + resolved.includes("/") || + resolved.includes("\\") + ) { + try { + await fs.access(resolved); + return true; + } catch { + return false; + } + } + + const command = process.platform === "win32" ? ["where", name] : ["/usr/bin/env", "which", name]; + try { + const result = await runCommandWithTimeout(command, { timeoutMs: 2000 }); + return result.code === 0 && result.stdout.trim().length > 0; + } catch { + return false; + } +} diff --git a/src/plugins/setup-browser.ts b/src/plugins/setup-browser.ts new file mode 100644 index 00000000000..eca0ab486bd --- /dev/null +++ b/src/plugins/setup-browser.ts @@ -0,0 +1,112 @@ +import { isWSL, isWSLEnv } from "../infra/wsl.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { detectBinary } from "./setup-binary.js"; + +function shouldSkipBrowserOpenInTests(): boolean { + if (process.env.VITEST) { + return true; + } + return process.env.NODE_ENV === "test"; +} + +type BrowserOpenCommand = { + argv: string[] | null; + command?: string; + quoteUrl?: boolean; +}; + +async function resolveBrowserOpenCommand(): Promise { + const platform = process.platform; + const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); + const isSsh = + Boolean(process.env.SSH_CLIENT) || + Boolean(process.env.SSH_TTY) || + Boolean(process.env.SSH_CONNECTION); + + if (isSsh && !hasDisplay && platform !== "win32") { + return { argv: null }; + } + + if (platform === "win32") { + return { + argv: ["cmd", "/c", "start", ""], + command: "cmd", + quoteUrl: true, + }; + } + + if (platform === "darwin") { + const hasOpen = await detectBinary("open"); + return hasOpen ? { argv: ["open"], command: "open" } : { argv: null }; + } + + if (platform === "linux") { + const wsl = await isWSL(); + if (!hasDisplay && !wsl) { + return { argv: null }; + } + if (wsl) { + const hasWslview = await detectBinary("wslview"); + if (hasWslview) { + return { argv: ["wslview"], command: "wslview" }; + } + if (!hasDisplay) { + return { argv: null }; + } + } + const hasXdgOpen = await detectBinary("xdg-open"); + return hasXdgOpen ? { argv: ["xdg-open"], command: "xdg-open" } : { argv: null }; + } + + return { argv: null }; +} + +export function isRemoteEnvironment(): boolean { + if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) { + return true; + } + + if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) { + return true; + } + + if ( + process.platform === "linux" && + !process.env.DISPLAY && + !process.env.WAYLAND_DISPLAY && + !isWSLEnv() + ) { + return true; + } + + return false; +} + +export async function openUrl(url: string): Promise { + if (shouldSkipBrowserOpenInTests()) { + return false; + } + const resolved = await resolveBrowserOpenCommand(); + if (!resolved.argv) { + return false; + } + const quoteUrl = resolved.quoteUrl === true; + const command = [...resolved.argv]; + if (quoteUrl) { + if (command.at(-1) === "") { + command[command.length - 1] = '""'; + } + command.push(`"${url}"`); + } else { + command.push(url); + } + try { + await runCommandWithTimeout(command, { + timeoutMs: 5_000, + windowsVerbatimArguments: quoteUrl, + }); + return true; + } catch { + return false; + } +} diff --git a/src/plugins/signal-cli-install.ts b/src/plugins/signal-cli-install.ts new file mode 100644 index 00000000000..a5c73392b4b --- /dev/null +++ b/src/plugins/signal-cli-install.ts @@ -0,0 +1,302 @@ +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import { request } from "node:https"; +import os from "node:os"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import { extractArchive } from "../infra/archive.js"; +import { resolveBrewExecutable } from "../infra/brew.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { CONFIG_DIR } from "../utils.js"; + +export type ReleaseAsset = { + name?: string; + browser_download_url?: string; +}; + +export type NamedAsset = { + name: string; + browser_download_url: string; +}; + +type ReleaseResponse = { + tag_name?: string; + assets?: ReleaseAsset[]; +}; + +export type SignalInstallResult = { + ok: boolean; + cliPath?: string; + version?: string; + error?: string; +}; + +/** @internal Exported for testing. */ +export async function extractSignalCliArchive( + archivePath: string, + installRoot: string, + timeoutMs: number, +): Promise { + await extractArchive({ archivePath, destDir: installRoot, timeoutMs }); +} + +/** @internal Exported for testing. */ +export function looksLikeArchive(name: string): boolean { + return name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip"); +} + +/** + * Pick a native release asset from the official GitHub releases. + * + * The official signal-cli releases only publish native (GraalVM) binaries for + * x86-64 Linux. On architectures where no native asset is available this + * returns `undefined` so the caller can fall back to a different install + * strategy (e.g. Homebrew). + */ +/** @internal Exported for testing. */ +export function pickAsset( + assets: ReleaseAsset[], + platform: NodeJS.Platform, + arch: string, +): NamedAsset | undefined { + const withName = assets.filter((asset): asset is NamedAsset => + Boolean(asset.name && asset.browser_download_url), + ); + + // Archives only, excluding signature files (.asc) + const archives = withName.filter((a) => looksLikeArchive(a.name.toLowerCase())); + + const byName = (pattern: RegExp) => + archives.find((asset) => pattern.test(asset.name.toLowerCase())); + + if (platform === "linux") { + // The official "Linux-native" asset is an x86-64 GraalVM binary. + // On non-x64 architectures it will fail with "Exec format error", + // so only select it when the host architecture matches. + if (arch === "x64") { + return byName(/linux-native/) || byName(/linux/) || archives[0]; + } + // No native release for this arch — caller should fall back. + return undefined; + } + + if (platform === "darwin") { + return byName(/macos|osx|darwin/) || archives[0]; + } + + if (platform === "win32") { + return byName(/windows|win/) || archives[0]; + } + + return archives[0]; +} + +async function downloadToFile(url: string, dest: string, maxRedirects = 5): Promise { + await new Promise((resolve, reject) => { + const req = request(url, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) { + const location = res.headers.location; + if (!location || maxRedirects <= 0) { + reject(new Error("Redirect loop or missing Location header")); + return; + } + const redirectUrl = new URL(location, url).href; + resolve(downloadToFile(redirectUrl, dest, maxRedirects - 1)); + return; + } + if (!res.statusCode || res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading file`)); + return; + } + const out = createWriteStream(dest); + pipeline(res, out).then(resolve).catch(reject); + }); + req.on("error", reject); + req.end(); + }); +} + +async function findSignalCliBinary(root: string): Promise { + const candidates: string[] = []; + const enqueue = async (dir: string, depth: number) => { + if (depth > 3) { + return; + } + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await enqueue(full, depth + 1); + } else if (entry.isFile() && entry.name === "signal-cli") { + candidates.push(full); + } + } + }; + await enqueue(root, 0); + return candidates[0] ?? null; +} + +// --------------------------------------------------------------------------- +// Brew-based install (used on architectures without an official native build) +// --------------------------------------------------------------------------- + +async function resolveBrewSignalCliPath(brewExe: string): Promise { + try { + const result = await runCommandWithTimeout([brewExe, "--prefix", "signal-cli"], { + timeoutMs: 10_000, + }); + if (result.code === 0 && result.stdout.trim()) { + const prefix = result.stdout.trim(); + // Homebrew installs the wrapper script at /bin/signal-cli + const candidate = path.join(prefix, "bin", "signal-cli"); + try { + await fs.access(candidate); + return candidate; + } catch { + // Fall back to searching the prefix + return findSignalCliBinary(prefix); + } + } + } catch { + // ignore + } + return null; +} + +async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise { + const brewExe = resolveBrewExecutable(); + if (!brewExe) { + return { + ok: false, + error: + `No native signal-cli build is available for ${process.arch}. ` + + "Install Homebrew (https://brew.sh) and try again, or install signal-cli manually.", + }; + } + + runtime.log(`Installing signal-cli via Homebrew (${brewExe})…`); + const result = await runCommandWithTimeout([brewExe, "install", "signal-cli"], { + timeoutMs: 15 * 60_000, // brew builds from source; can take a while + }); + + if (result.code !== 0) { + return { + ok: false, + error: `brew install signal-cli failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`, + }; + } + + const cliPath = await resolveBrewSignalCliPath(brewExe); + if (!cliPath) { + return { + ok: false, + error: "brew install succeeded but signal-cli binary was not found.", + }; + } + + // Extract version from the installed binary. + let version: string | undefined; + try { + const vResult = await runCommandWithTimeout([cliPath, "--version"], { + timeoutMs: 10_000, + }); + // Output is typically "signal-cli 0.13.24" + version = vResult.stdout.trim().replace(/^signal-cli\s+/, "") || undefined; + } catch { + // non-critical; leave version undefined + } + + return { ok: true, cliPath, version }; +} + +// --------------------------------------------------------------------------- +// Direct download install (used when an official native asset is available) +// --------------------------------------------------------------------------- + +async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise { + const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest"; + const response = await fetch(apiUrl, { + headers: { + "User-Agent": "openclaw", + Accept: "application/vnd.github+json", + }, + }); + + if (!response.ok) { + return { + ok: false, + error: `Failed to fetch release info (${response.status})`, + }; + } + + const payload = (await response.json()) as ReleaseResponse; + const version = payload.tag_name?.replace(/^v/, "") ?? "unknown"; + const assets = payload.assets ?? []; + const asset = pickAsset(assets, process.platform, process.arch); + + if (!asset) { + return { + ok: false, + error: "No compatible release asset found for this platform.", + }; + } + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-")); + const archivePath = path.join(tmpDir, asset.name); + + runtime.log(`Downloading signal-cli ${version} (${asset.name})…`); + await downloadToFile(asset.browser_download_url, archivePath); + + const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version); + await fs.mkdir(installRoot, { recursive: true }); + + if (!looksLikeArchive(asset.name.toLowerCase())) { + return { ok: false, error: `Unsupported archive type: ${asset.name}` }; + } + try { + await extractSignalCliArchive(archivePath, installRoot, 60_000); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + error: `Failed to extract ${asset.name}: ${message}`, + }; + } + + const cliPath = await findSignalCliBinary(installRoot); + if (!cliPath) { + return { + ok: false, + error: `signal-cli binary not found after extracting ${asset.name}`, + }; + } + + await fs.chmod(cliPath, 0o755).catch(() => {}); + + return { ok: true, cliPath, version }; +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +export async function installSignalCli(runtime: RuntimeEnv): Promise { + if (process.platform === "win32") { + return { + ok: false, + error: "Signal CLI auto-install is not supported on Windows yet.", + }; + } + + // The official signal-cli GitHub releases only ship a native binary for + // x86-64 Linux. On other architectures (arm64, armv7, etc.) we delegate + // to Homebrew which builds from source and bundles the JRE automatically. + const hasNativeRelease = process.platform !== "linux" || process.arch === "x64"; + + if (hasNativeRelease) { + return installSignalCliFromRelease(runtime); + } + + return installSignalCliViaBrew(runtime); +} From 7f042758b05ce3b4946a128f4d3b1a717515ac56 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 22:12:23 -0700 Subject: [PATCH 105/128] Sandbox: decouple built-in channel ids --- src/agents/sandbox/constants.ts | 2 +- src/channels/ids.ts | 18 ++++++++++++++++++ src/channels/registry.ts | 21 +++------------------ 3 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 src/channels/ids.ts diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index 8e906eb9432..80915b3bfce 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { CHANNEL_IDS } from "../../channels/registry.js"; +import { CHANNEL_IDS } from "../../channels/ids.js"; import { STATE_DIR } from "../../config/paths.js"; export const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(STATE_DIR, "sandboxes"); diff --git a/src/channels/ids.ts b/src/channels/ids.ts new file mode 100644 index 00000000000..cddfe667250 --- /dev/null +++ b/src/channels/ids.ts @@ -0,0 +1,18 @@ +// Keep built-in channel IDs in a leaf module so shared config/sandbox code can +// reference them without importing channel registry helpers that may pull in +// plugin runtime state. +export const CHAT_CHANNEL_ORDER = [ + "telegram", + "whatsapp", + "discord", + "irc", + "googlechat", + "slack", + "signal", + "imessage", + "line", +] as const; + +export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number]; + +export const CHANNEL_IDS = [...CHAT_CHANNEL_ORDER] as const; diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 16ba6514397..5e552e04a0e 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -1,24 +1,9 @@ import { requireActivePluginRegistry } from "../plugins/runtime.js"; +import { CHANNEL_IDS, CHAT_CHANNEL_ORDER, type ChatChannelId } from "./ids.js"; import type { ChannelMeta } from "./plugins/types.js"; import type { ChannelId } from "./plugins/types.js"; - -// Channel docking: add new core channels here (order + meta + aliases), then -// register the plugin in its extension entrypoint and keep protocol IDs in sync. -export const CHAT_CHANNEL_ORDER = [ - "telegram", - "whatsapp", - "discord", - "irc", - "googlechat", - "slack", - "signal", - "imessage", - "line", -] as const; - -export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number]; - -export const CHANNEL_IDS = [...CHAT_CHANNEL_ORDER] as const; +export { CHANNEL_IDS, CHAT_CHANNEL_ORDER } from "./ids.js"; +export type { ChatChannelId } from "./ids.js"; export type ChatChannelMeta = ChannelMeta; From d20363bcc9749f371e82e7646957e0d5ae925e60 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:17:07 +0000 Subject: [PATCH 106/128] refactor(channels): remove dead shared plugin duplicates --- extensions/slack/src/plugin-shared.ts | 53 ------------------- extensions/telegram/src/plugin-shared.ts | 65 ------------------------ 2 files changed, 118 deletions(-) delete mode 100644 extensions/slack/src/plugin-shared.ts delete mode 100644 extensions/telegram/src/plugin-shared.ts diff --git a/extensions/slack/src/plugin-shared.ts b/extensions/slack/src/plugin-shared.ts deleted file mode 100644 index 0a5eb6ea3ec..00000000000 --- a/extensions/slack/src/plugin-shared.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/slack"; -import { inspectSlackAccount } from "./account-inspect.js"; -import { - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, - type ResolvedSlackAccount, -} from "./accounts.js"; -import { createSlackSetupWizardProxy } from "./setup-core.js"; - -async function loadSlackChannelRuntime() { - return await import("./channel.runtime.js"); -} - -export 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()); -} - -export const isSlackPluginAccountConfigured = isSlackAccountConfigured; - -export const slackConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => - 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", - listAccountIds: listSlackAccountIds, - resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultSlackAccountId, - clearBaseFields: ["botToken", "appToken", "name"], -}); - -export const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ - slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, -})); diff --git a/extensions/telegram/src/plugin-shared.ts b/extensions/telegram/src/plugin-shared.ts deleted file mode 100644 index 12562f0da61..00000000000 --- a/extensions/telegram/src/plugin-shared.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { normalizeAccountId, type OpenClawConfig } from "openclaw/plugin-sdk/telegram"; -import { inspectTelegramAccount } from "./account-inspect.js"; -import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, - type ResolvedTelegramAccount, -} from "./accounts.js"; - -export function findTelegramTokenOwnerAccountId(params: { - cfg: OpenClawConfig; - accountId: string; -}): string | null { - const normalizedAccountId = normalizeAccountId(params.accountId); - const tokenOwners = new Map(); - for (const id of listTelegramAccountIds(params.cfg)) { - const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); - const token = (account.token ?? "").trim(); - if (!token) { - continue; - } - const ownerAccountId = tokenOwners.get(token); - if (!ownerAccountId) { - tokenOwners.set(token, account.accountId); - continue; - } - if (account.accountId === normalizedAccountId) { - return ownerAccountId; - } - } - return null; -} - -export function formatDuplicateTelegramTokenReason(params: { - accountId: string; - ownerAccountId: string; -}): string { - return ( - `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + - `account "${params.ownerAccountId}". Keep one owner account per bot token.` - ); -} - -export const telegramConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => - resolveTelegramAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), - resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, -}); - -export const telegramConfigBase = createScopedChannelConfigBase({ - sectionKey: "telegram", - listAccountIds: listTelegramAccountIds, - resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultTelegramAccountId, - clearBaseFields: ["botToken", "tokenFile", "name"], -}); From dd85ff4da75a5f047a2fa85ff8ac4b1178a42fe0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:18:35 +0000 Subject: [PATCH 107/128] refactor(tlon): share setup wizard base --- extensions/tlon/src/channel.ts | 100 ++++------------------- extensions/tlon/src/setup-core.ts | 116 ++++++++++++++++++++++++++- extensions/tlon/src/setup-surface.ts | 103 +++--------------------- 3 files changed, 144 insertions(+), 175 deletions(-) diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 4442279a727..fa7c702354d 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,7 +1,11 @@ import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/tlon"; import { tlonChannelConfigSchema } from "./config-schema.js"; -import { tlonSetupAdapter } from "./setup-core.js"; -import { applyTlonSetupConfig } from "./setup-core.js"; +import { + applyTlonSetupConfig, + createTlonSetupWizardBase, + resolveTlonSetupConfigured, + tlonSetupAdapter, +} from "./setup-core.js"; import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; import { validateUrbitBaseUrl } from "./urbit/base-url.js"; @@ -15,91 +19,21 @@ async function loadTlonChannelRuntime() { return tlonChannelRuntimePromise; } -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, - })) ?? [], - }, - 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", - ], - }, - 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 }, - }), - }, - ], +const tlonSetupWizardProxy = createTlonSetupWizardBase({ + resolveConfigured: async ({ cfg }) => + await (await loadTlonChannelRuntime()).tlonSetupWizard.status.resolveConfigured({ cfg }), + resolveStatusLines: async ({ cfg, configured }) => + (await ( + await loadTlonChannelRuntime() + ).tlonSetupWizard.status.resolveStatusLines?.({ + cfg, + configured, + })) ?? [], finalize: async (params) => await ( await loadTlonChannelRuntime() ).tlonSetupWizard.finalize!(params), -} satisfies NonNullable; +}) satisfies NonNullable; export const tlonPlugin: ChannelPlugin = { id: TLON_CHANNEL_ID, diff --git a/extensions/tlon/src/setup-core.ts b/extensions/tlon/src/setup-core.ts index 846af4f08a3..8d54e37444a 100644 --- a/extensions/tlon/src/setup-core.ts +++ b/extensions/tlon/src/setup-core.ts @@ -1,14 +1,19 @@ import { DEFAULT_ACCOUNT_ID, + formatDocsLink, normalizeAccountId, patchScopedAccountConfig, prepareScopedSetupConfig, type ChannelSetupAdapter, type ChannelSetupInput, + type ChannelSetupWizard, type OpenClawConfig, + type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import { buildTlonAccountFields } from "./account-fields.js"; -import { resolveTlonAccount } from "./types.js"; +import { normalizeShip } from "./targets.js"; +import { listTlonAccountIds, resolveTlonAccount, type TlonResolvedAccount } from "./types.js"; +import { validateUrbitBaseUrl } from "./urbit/base-url.js"; const channel = "tlon" as const; @@ -23,6 +28,115 @@ export type TlonSetupInput = ChannelSetupInput & { ownerShip?: string; }; +function isConfigured(account: TlonResolvedAccount): boolean { + return Boolean(account.ship && account.url && account.code); +} + +type TlonSetupWizardBaseParams = { + resolveConfigured: (params: { cfg: OpenClawConfig }) => boolean | Promise; + resolveStatusLines?: (params: { + cfg: OpenClawConfig; + configured: boolean; + }) => string[] | Promise; + finalize: (params: { + cfg: OpenClawConfig; + accountId: string; + prompter: WizardPrompter; + options?: Record; + }) => Promise<{ cfg: OpenClawConfig }>; +}; + +export function createTlonSetupWizardBase(params: TlonSetupWizardBaseParams): ChannelSetupWizard { + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "configured", + unconfiguredHint: "urbit messenger", + configuredScore: 1, + unconfiguredScore: 4, + resolveConfigured: ({ cfg }) => params.resolveConfigured({ cfg }), + resolveStatusLines: ({ cfg, configured }) => + params.resolveStatusLines?.({ cfg, configured }) ?? [], + }, + 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: ${formatDocsLink("/channels/tlon", "channels/tlon")}`, + ], + }, + 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: params.finalize, + }; +} + +export async function resolveTlonSetupConfigured(cfg: OpenClawConfig): Promise { + const accountIds = listTlonAccountIds(cfg); + return accountIds.length > 0 + ? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId))) + : isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID)); +} + +export async function resolveTlonSetupStatusLines(cfg: OpenClawConfig): Promise { + const configured = await resolveTlonSetupConfigured(cfg); + return [`Tlon: ${configured ? "configured" : "needs setup"}`]; +} + export function applyTlonSetupConfig(params: { cfg: OpenClawConfig; accountId: string; diff --git a/extensions/tlon/src/setup-surface.ts b/extensions/tlon/src/setup-surface.ts index e3c1b43f0c1..bf4ce6fbf2e 100644 --- a/extensions/tlon/src/setup-surface.ts +++ b/extensions/tlon/src/setup-surface.ts @@ -1,9 +1,12 @@ +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; import { - DEFAULT_ACCOUNT_ID, - formatDocsLink, - type ChannelSetupWizard, -} from "openclaw/plugin-sdk/setup"; -import { applyTlonSetupConfig, type TlonSetupInput, tlonSetupAdapter } from "./setup-core.js"; + applyTlonSetupConfig, + createTlonSetupWizardBase, + resolveTlonSetupConfigured, + resolveTlonSetupStatusLines, + type TlonSetupInput, + tlonSetupAdapter, +} from "./setup-core.js"; import { normalizeShip } from "./targets.js"; import { listTlonAccountIds, resolveTlonAccount, type TlonResolvedAccount } from "./types.js"; import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js"; @@ -23,91 +26,9 @@ function parseList(value: string): string[] { export { tlonSetupAdapter } from "./setup-core.js"; -export const tlonSetupWizard: ChannelSetupWizard = { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs setup", - configuredHint: "configured", - unconfiguredHint: "urbit messenger", - configuredScore: 1, - unconfiguredScore: 4, - resolveConfigured: ({ cfg }) => { - const accountIds = listTlonAccountIds(cfg); - return accountIds.length > 0 - ? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId))) - : isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID)); - }, - resolveStatusLines: ({ cfg }) => { - const accountIds = listTlonAccountIds(cfg); - const configured = - accountIds.length > 0 - ? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId))) - : isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID)); - return [`Tlon: ${configured ? "configured" : "needs setup"}`]; - }, - }, - 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: ${formatDocsLink("/channels/tlon", "channels/tlon")}`, - ], - }, - 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 }, - }), - }, - ], +export const tlonSetupWizard = createTlonSetupWizardBase({ + resolveConfigured: async ({ cfg }) => await resolveTlonSetupConfigured(cfg), + resolveStatusLines: async ({ cfg }) => await resolveTlonSetupStatusLines(cfg), finalize: async ({ cfg, accountId, prompter }) => { let next = cfg; const resolved = resolveTlonAccount(next, accountId); @@ -183,4 +104,4 @@ export const tlonSetupWizard: ChannelSetupWizard = { return { cfg: next }; }, -}; +}); From ed06d21013f2128639a32bbf1b9d345877177a37 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:20:13 +0000 Subject: [PATCH 108/128] refactor(providers): share template model cloning --- extensions/google/provider-models.ts | 28 +---------- extensions/openai/shared.ts | 50 ++----------------- src/plugins/provider-model-helpers.test.ts | 56 ++++++++++++++++++++++ src/plugins/provider-model-helpers.ts | 28 +++++++++++ 4 files changed, 90 insertions(+), 72 deletions(-) create mode 100644 src/plugins/provider-model-helpers.test.ts create mode 100644 src/plugins/provider-model-helpers.ts diff --git a/extensions/google/provider-models.ts b/extensions/google/provider-models.ts index eddda4a9f9a..ddb0446c2b9 100644 --- a/extensions/google/provider-models.ts +++ b/extensions/google/provider-models.ts @@ -1,39 +1,14 @@ +import { cloneFirstTemplateModel } from "../../src/plugins/provider-model-helpers.js"; import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; -function cloneFirstTemplateModel(params: { - providerId: string; - modelId: string; - templateIds: readonly string[]; - ctx: ProviderResolveDynamicModelContext; -}): ProviderRuntimeModel | undefined { - const trimmedModelId = params.modelId.trim(); - for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { - const template = params.ctx.modelRegistry.find( - params.providerId, - templateId, - ) as ProviderRuntimeModel | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - reasoning: true, - } as ProviderRuntimeModel); - } - return undefined; -} - export function resolveGoogle31ForwardCompatModel(params: { providerId: string; ctx: ProviderResolveDynamicModelContext; @@ -55,6 +30,7 @@ export function resolveGoogle31ForwardCompatModel(params: { modelId: trimmed, templateIds, ctx: params.ctx, + patch: { reasoning: true }, }); } diff --git a/extensions/openai/shared.ts b/extensions/openai/shared.ts index 2b67454fc07..673a6bdeb24 100644 --- a/extensions/openai/shared.ts +++ b/extensions/openai/shared.ts @@ -1,8 +1,5 @@ -import type { - ProviderResolveDynamicModelContext, - ProviderRuntimeModel, -} from "openclaw/plugin-sdk/core"; -import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; +import { findCatalogTemplate } from "../../src/plugins/provider-catalog.js"; +import { cloneFirstTemplateModel } from "../../src/plugins/provider-model-helpers.js"; export const OPENAI_API_BASE_URL = "https://api.openai.com/v1"; @@ -22,44 +19,5 @@ export function isOpenAIApiBaseUrl(baseUrl?: string): boolean { return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); } -export function cloneFirstTemplateModel(params: { - providerId: string; - modelId: string; - templateIds: readonly string[]; - ctx: ProviderResolveDynamicModelContext; - patch?: Partial; -}): ProviderRuntimeModel | undefined { - const trimmedModelId = params.modelId.trim(); - for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { - const template = params.ctx.modelRegistry.find( - params.providerId, - templateId, - ) as ProviderRuntimeModel | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - ...params.patch, - } as ProviderRuntimeModel); - } - return undefined; -} - -export 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 { cloneFirstTemplateModel }; +export { findCatalogTemplate }; diff --git a/src/plugins/provider-model-helpers.test.ts b/src/plugins/provider-model-helpers.test.ts new file mode 100644 index 00000000000..905195775fe --- /dev/null +++ b/src/plugins/provider-model-helpers.test.ts @@ -0,0 +1,56 @@ +import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import { cloneFirstTemplateModel } from "./provider-model-helpers.js"; +import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel } from "./types.js"; + +function createContext(models: ProviderRuntimeModel[]): ProviderResolveDynamicModelContext { + return { + provider: "test-provider", + modelId: "next-model", + modelRegistry: { + find(providerId: string, modelId: string) { + return ( + models.find((model) => model.provider === providerId && model.id === modelId) ?? null + ); + }, + } as ModelRegistry, + }; +} + +describe("cloneFirstTemplateModel", () => { + it("clones the first matching template and applies patches", () => { + const model = cloneFirstTemplateModel({ + providerId: "test-provider", + modelId: " next-model ", + templateIds: ["missing", "template-a", "template-b"], + ctx: createContext([ + { + id: "template-a", + name: "Template A", + provider: "test-provider", + api: "openai-completions", + } as ProviderRuntimeModel, + ]), + patch: { reasoning: true }, + }); + + expect(model).toMatchObject({ + id: "next-model", + name: "next-model", + provider: "test-provider", + api: "openai-completions", + reasoning: true, + }); + }); + + it("returns undefined when no template exists", () => { + const model = cloneFirstTemplateModel({ + providerId: "test-provider", + modelId: "next-model", + templateIds: ["missing"], + ctx: createContext([]), + }); + + expect(model).toBeUndefined(); + }); +}); diff --git a/src/plugins/provider-model-helpers.ts b/src/plugins/provider-model-helpers.ts new file mode 100644 index 00000000000..8ffd8d18be7 --- /dev/null +++ b/src/plugins/provider-model-helpers.ts @@ -0,0 +1,28 @@ +import { normalizeModelCompat } from "../agents/model-compat.js"; +import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel } from "./types.js"; + +export function cloneFirstTemplateModel(params: { + providerId: string; + modelId: string; + templateIds: readonly string[]; + ctx: ProviderResolveDynamicModelContext; + patch?: Partial; +}): ProviderRuntimeModel | undefined { + const trimmedModelId = params.modelId.trim(); + for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { + const template = params.ctx.modelRegistry.find( + params.providerId, + templateId, + ) as ProviderRuntimeModel | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + ...params.patch, + } as ProviderRuntimeModel); + } + return undefined; +} From 966b8656d2d497fa35b0c2e2d7cad128cd0b7e67 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:21:19 +0000 Subject: [PATCH 109/128] refactor(tlon): share outbound target resolution --- extensions/tlon/src/channel.runtime.ts | 21 +++++++------------- extensions/tlon/src/channel.ts | 21 +++++++------------- extensions/tlon/src/targets.test.ts | 27 ++++++++++++++++++++++++++ extensions/tlon/src/targets.ts | 14 +++++++++++++ 4 files changed, 55 insertions(+), 28 deletions(-) create mode 100644 extensions/tlon/src/targets.test.ts diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts index f9f11e4620c..525359a2a4e 100644 --- a/extensions/tlon/src/channel.runtime.ts +++ b/extensions/tlon/src/channel.runtime.ts @@ -7,7 +7,12 @@ import type { } 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 { + formatTargetHint, + normalizeShip, + parseTlonTarget, + resolveTlonOutboundTarget, +} from "./targets.js"; import { resolveTlonAccount } from "./types.js"; import { authenticate } from "./urbit/auth.js"; import { ssrfPolicyFromAllowPrivateNetwork } from "./urbit/context.js"; @@ -131,19 +136,7 @@ async function withHttpPokeAccountApi( 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 }; - }, + resolveTarget: ({ to }) => resolveTlonOutboundTarget(to), sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { const { account, parsed } = resolveOutboundContext({ cfg, accountId, to }); return withHttpPokeAccountApi(account, async (api) => { diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index fa7c702354d..5f754201ac1 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -6,7 +6,12 @@ import { resolveTlonSetupConfigured, tlonSetupAdapter, } from "./setup-core.js"; -import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; +import { + formatTargetHint, + normalizeShip, + parseTlonTarget, + resolveTlonOutboundTarget, +} from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; import { validateUrbitBaseUrl } from "./urbit/base-url.js"; @@ -151,19 +156,7 @@ export const tlonPlugin: ChannelPlugin = { 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 }; - }, + resolveTarget: ({ to }) => resolveTlonOutboundTarget(to), sendText: async (params) => await ( await loadTlonChannelRuntime() diff --git a/extensions/tlon/src/targets.test.ts b/extensions/tlon/src/targets.test.ts new file mode 100644 index 00000000000..3ac4d010f38 --- /dev/null +++ b/extensions/tlon/src/targets.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { resolveTlonOutboundTarget } from "./targets.js"; + +describe("resolveTlonOutboundTarget", () => { + it("resolves dm targets to normalized ships", () => { + expect(resolveTlonOutboundTarget("dm/sampel-palnet")).toEqual({ + ok: true, + to: "~sampel-palnet", + }); + }); + + it("resolves group targets to canonical chat nests", () => { + expect(resolveTlonOutboundTarget("group:host-ship/general")).toEqual({ + ok: true, + to: "chat/~host-ship/general", + }); + }); + + it("returns a helpful error for invalid targets", () => { + const resolved = resolveTlonOutboundTarget("group:bad-target"); + expect(resolved.ok).toBe(false); + if (resolved.ok) { + throw new Error("expected invalid target"); + } + expect(resolved.error.message).toMatch(/invalid tlon target/i); + }); +}); diff --git a/extensions/tlon/src/targets.ts b/extensions/tlon/src/targets.ts index bacc6d576c0..b8aa17e5e8c 100644 --- a/extensions/tlon/src/targets.ts +++ b/extensions/tlon/src/targets.ts @@ -84,6 +84,20 @@ export function parseTlonTarget(raw?: string | null): TlonTarget | null { return null; } +export function resolveTlonOutboundTarget(to?: string | null) { + const parsed = parseTlonTarget(to ?? ""); + if (!parsed) { + return { + ok: false as const, + error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`), + }; + } + if (parsed.kind === "dm") { + return { ok: true as const, to: parsed.ship }; + } + return { ok: true as const, to: parsed.nest }; +} + export function formatTargetHint(): string { return "dm/~sampel-palnet | ~sampel-palnet | chat/~host-ship/channel | group:~host-ship/channel"; } From 10660fe47dc0222a247058b355d4a201e69f4c28 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:24:01 +0000 Subject: [PATCH 110/128] refactor(channels): share legacy dm allowlist paths --- extensions/discord/src/channel.ts | 14 +++++--------- extensions/slack/src/channel.ts | 14 +++++--------- src/plugin-sdk/allowlist-config-edit.ts | 10 ++++++++++ src/plugin-sdk/index.ts | 5 ++++- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index d12813e66a6..a9db3a7937f 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,5 +1,8 @@ import { Separator, TextDisplay } from "@buape/carbon"; -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { + buildAccountScopedAllowlistConfigEditor, + resolveLegacyDmAllowlistConfigPaths, +} from "openclaw/plugin-sdk/allowlist-config-edit"; import { buildAccountScopedDmSecurityPolicy, collectOpenGroupPolicyConfiguredRouteWarnings, @@ -347,14 +350,7 @@ export const discordPlugin: ChannelPlugin = { channelId: "discord", normalize: ({ cfg, accountId, values }) => discordConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: (scope) => - scope === "dm" - ? { - readPaths: [["allowFrom"], ["dm", "allowFrom"]], - writePath: ["allowFrom"], - cleanupPaths: [["dm", "allowFrom"]], - } - : null, + resolvePaths: resolveLegacyDmAllowlistConfigPaths, }), }, security: { diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 3dfb195be86..4890ab88eaa 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,4 +1,7 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { + buildAccountScopedAllowlistConfigEditor, + resolveLegacyDmAllowlistConfigPaths, +} from "openclaw/plugin-sdk/allowlist-config-edit"; import { buildAccountScopedDmSecurityPolicy, collectOpenGroupPolicyConfiguredRouteWarnings, @@ -410,14 +413,7 @@ export const slackPlugin: ChannelPlugin = { channelId: "slack", normalize: ({ cfg, accountId, values }) => slackConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: (scope) => - scope === "dm" - ? { - readPaths: [["allowFrom"], ["dm", "allowFrom"]], - writePath: ["allowFrom"], - cleanupPaths: [["dm", "allowFrom"]], - } - : null, + resolvePaths: resolveLegacyDmAllowlistConfigPaths, }), }, security: { diff --git a/src/plugin-sdk/allowlist-config-edit.ts b/src/plugin-sdk/allowlist-config-edit.ts index c9f2a92e3be..e92e4cb8551 100644 --- a/src/plugin-sdk/allowlist-config-edit.ts +++ b/src/plugin-sdk/allowlist-config-edit.ts @@ -11,6 +11,16 @@ type AllowlistConfigPaths = { cleanupPaths?: string[][]; }; +const LEGACY_DM_ALLOWLIST_CONFIG_PATHS: AllowlistConfigPaths = { + readPaths: [["allowFrom"], ["dm", "allowFrom"]], + writePath: ["allowFrom"], + cleanupPaths: [["dm", "allowFrom"]], +}; + +export function resolveLegacyDmAllowlistConfigPaths(scope: "dm" | "group") { + return scope === "dm" ? LEGACY_DM_ALLOWLIST_CONFIG_PATHS : null; +} + function resolveAccountScopedWriteTarget( parsed: Record, channelId: ChannelId, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 50949a31a89..acfca49d6ab 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -270,7 +270,10 @@ export { buildChannelSendResult } from "./channel-send-result.js"; export type { ChannelSendRawResult } from "./channel-send-result.js"; export { createPluginRuntimeStore } from "./runtime-store.js"; export { createScopedChannelConfigBase } from "./channel-config-helpers.js"; -export { buildAccountScopedAllowlistConfigEditor } from "./allowlist-config-edit.js"; +export { + buildAccountScopedAllowlistConfigEditor, + resolveLegacyDmAllowlistConfigPaths, +} from "./allowlist-config-edit.js"; export { AllowFromEntrySchema, AllowFromListSchema, From b0dd757ec8a4ae90815a54bd963aaf0f7465e98b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:28:42 +0000 Subject: [PATCH 111/128] refactor(discord): share monitor provider test harness --- .../src/monitor/provider.registry.test.ts | 340 +------------- .../src/monitor/provider.test-support.ts | 426 ++++++++++++++++++ .../discord/src/monitor/provider.test.ts | 394 +--------------- 3 files changed, 454 insertions(+), 706 deletions(-) create mode 100644 extensions/discord/src/monitor/provider.test-support.ts diff --git a/extensions/discord/src/monitor/provider.registry.test.ts b/extensions/discord/src/monitor/provider.registry.test.ts index bffe979973b..2187c851f69 100644 --- a/extensions/discord/src/monitor/provider.registry.test.ts +++ b/extensions/discord/src/monitor/provider.registry.test.ts @@ -1,339 +1,21 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { beforeEach, describe, expect, it } from "vitest"; import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { + baseConfig, + baseRuntime, + getProviderMonitorTestMocks, + resetDiscordProviderMonitorMocks, +} from "./provider.test-support.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, -})); +const { createDiscordNativeCommandMock, clientHandleDeployRequestMock, monitorLifecycleMock } = + getProviderMonitorTestMocks(); 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(); + resetDiscordProviderMonitorMocks({ + nativeCommands: [{ name: "status", description: "Status", acceptsArgs: false }], }); - 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 () => { diff --git a/extensions/discord/src/monitor/provider.test-support.ts b/extensions/discord/src/monitor/provider.test-support.ts new file mode 100644 index 00000000000..932c1952fcc --- /dev/null +++ b/extensions/discord/src/monitor/provider.test-support.ts @@ -0,0 +1,426 @@ +import { expect, vi } from "vitest"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; + +export type NativeCommandSpecMock = { + name: string; + description: string; + acceptsArgs: boolean; +}; + +export type PluginCommandSpecMock = { + name: string; + description: string; + acceptsArgs: boolean; +}; + +export function baseDiscordAccountConfig() { + return { + commands: { native: true, nativeSkills: false }, + voice: { enabled: false }, + agentComponents: { enabled: false }, + execApprovals: { enabled: false }, + }; +} + +const providerMonitorTestMocks = vi.hoisted(() => { + const createdBindingManagers: Array<{ stop: ReturnType }> = []; + const isVerboseMock = vi.fn(() => false); + const shouldLogVerboseMock = vi.fn(() => false); + + return { + clientHandleDeployRequestMock: vi.fn(async () => undefined), + clientFetchUserMock: vi.fn(async (_target: string) => ({ id: "bot-1" })), + clientGetPluginMock: vi.fn<(_name: string) => unknown>(() => undefined), + clientConstructorOptionsMock: vi.fn(), + createDiscordAutoPresenceControllerMock: vi.fn(() => ({ + enabled: false, + start: vi.fn(), + stop: vi.fn(), + refresh: vi.fn(), + runNow: vi.fn(), + })), + createDiscordNativeCommandMock: vi.fn((params?: { command?: { name?: string } }) => ({ + name: params?.command?.name ?? "mock-command", + })), + createDiscordMessageHandlerMock: vi.fn(() => + Object.assign( + vi.fn(async () => undefined), + { + deactivate: vi.fn(), + }, + ), + ), + createNoopThreadBindingManagerMock: vi.fn(() => { + const manager = { stop: vi.fn() }; + createdBindingManagers.push(manager); + return manager; + }), + createThreadBindingManagerMock: vi.fn(() => { + const manager = { stop: vi.fn() }; + createdBindingManagers.push(manager); + return manager; + }), + reconcileAcpThreadBindingsOnStartupMock: vi.fn(() => ({ + checked: 0, + removed: 0, + staleSessionKeys: [], + })), + createdBindingManagers, + getAcpSessionStatusMock: vi.fn( + async (_params: { cfg: OpenClawConfig; sessionKey: string; signal?: AbortSignal }) => ({ + state: "idle", + }), + ), + getPluginCommandSpecsMock: vi.fn<() => PluginCommandSpecMock[]>(() => []), + listNativeCommandSpecsForConfigMock: vi.fn<() => NativeCommandSpecMock[]>(() => [ + { name: "cmd", description: "built-in", acceptsArgs: false }, + ]), + listSkillCommandsForAgentsMock: vi.fn(() => []), + monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => { + params.threadBindings.stop(); + }), + 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), + isVerboseMock, + shouldLogVerboseMock, + voiceRuntimeModuleLoadedMock: vi.fn(), + }; +}); + +const { + clientHandleDeployRequestMock, + clientFetchUserMock, + clientGetPluginMock, + clientConstructorOptionsMock, + createDiscordAutoPresenceControllerMock, + createDiscordNativeCommandMock, + createDiscordMessageHandlerMock, + createNoopThreadBindingManagerMock, + createThreadBindingManagerMock, + reconcileAcpThreadBindingsOnStartupMock, + createdBindingManagers, + getAcpSessionStatusMock, + getPluginCommandSpecsMock, + listNativeCommandSpecsForConfigMock, + listSkillCommandsForAgentsMock, + monitorLifecycleMock, + resolveDiscordAccountMock, + resolveDiscordAllowlistConfigMock, + resolveNativeCommandsEnabledMock, + resolveNativeSkillsEnabledMock, + isVerboseMock, + shouldLogVerboseMock, + voiceRuntimeModuleLoadedMock, +} = providerMonitorTestMocks; + +export function getProviderMonitorTestMocks() { + return providerMonitorTestMocks; +} + +export function mockResolvedDiscordAccountConfig(overrides: Record) { + resolveDiscordAccountMock.mockImplementation(() => ({ + accountId: "default", + token: "cfg-token", + config: { + ...baseDiscordAccountConfig(), + ...overrides, + }, + })); +} + +export function getFirstDiscordMessageHandlerParams() { + expect(createDiscordMessageHandlerMock).toHaveBeenCalledTimes(1); + const firstCall = createDiscordMessageHandlerMock.mock.calls.at(0) as [T] | undefined; + return firstCall?.[0]; +} + +export function resetDiscordProviderMonitorMocks(params?: { + nativeCommands?: NativeCommandSpecMock[]; +}) { + clientHandleDeployRequestMock.mockClear().mockResolvedValue(undefined); + clientFetchUserMock.mockClear().mockResolvedValue({ id: "bot-1" }); + clientGetPluginMock.mockClear().mockReturnValue(undefined); + clientConstructorOptionsMock.mockClear(); + createDiscordAutoPresenceControllerMock.mockClear().mockImplementation(() => ({ + enabled: false, + start: vi.fn(), + stop: vi.fn(), + refresh: vi.fn(), + runNow: vi.fn(), + })); + createDiscordNativeCommandMock.mockClear().mockImplementation((input) => ({ + name: input?.command?.name ?? "mock-command", + })); + createDiscordMessageHandlerMock.mockClear().mockImplementation(() => + Object.assign( + vi.fn(async () => undefined), + { + deactivate: vi.fn(), + }, + ), + ); + createNoopThreadBindingManagerMock.mockClear(); + createThreadBindingManagerMock.mockClear(); + reconcileAcpThreadBindingsOnStartupMock.mockClear().mockReturnValue({ + checked: 0, + removed: 0, + staleSessionKeys: [], + }); + createdBindingManagers.length = 0; + getAcpSessionStatusMock.mockClear().mockResolvedValue({ state: "idle" }); + getPluginCommandSpecsMock.mockClear().mockReturnValue([]); + listNativeCommandSpecsForConfigMock + .mockClear() + .mockReturnValue( + params?.nativeCommands ?? [{ name: "cmd", description: "built-in", acceptsArgs: false }], + ); + listSkillCommandsForAgentsMock.mockClear().mockReturnValue([]); + monitorLifecycleMock.mockClear().mockImplementation(async (monitorParams) => { + monitorParams.threadBindings.stop(); + }); + 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); + isVerboseMock.mockClear().mockReturnValue(false); + shouldLogVerboseMock.mockClear().mockReturnValue(false); + voiceRuntimeModuleLoadedMock.mockClear(); +} + +export const baseRuntime = (): RuntimeEnv => ({ + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}); + +export const baseConfig = (): OpenClawConfig => + ({ + channels: { + discord: { + accounts: { + default: {}, + }, + }, + }, + }) as OpenClawConfig; + +vi.mock("@buape/carbon", () => { + class ReadyListener {} + class RateLimitError extends Error { + status = 429; + discordCode?: number; + retryAfter: number; + scope: string | null; + bucket: string | null; + constructor( + response: Response, + body: { message: string; retry_after: number; global: boolean }, + ) { + super(body.message); + this.retryAfter = body.retry_after; + this.scope = body.global ? "global" : response.headers.get("X-RateLimit-Scope"); + this.bucket = response.headers.get("X-RateLimit-Bucket"); + } + } + class Client { + listeners: unknown[]; + rest: { put: ReturnType }; + options: unknown; + constructor(options: unknown, handlers: { listeners?: unknown[] }) { + this.options = options; + this.listeners = handlers.listeners ?? []; + this.rest = { put: vi.fn(async () => undefined) }; + clientConstructorOptionsMock(options); + } + async handleDeployRequest() { + return await clientHandleDeployRequestMock(); + } + async fetchUser(target: string) { + return await clientFetchUserMock(target); + } + getPlugin(name: string) { + return clientGetPluginMock(name); + } + } + 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: isVerboseMock, + logVerbose: vi.fn(), + shouldLogVerbose: shouldLogVerboseMock, + 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, +})); diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index f00baf73ff8..14177aec001 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -2,262 +2,45 @@ import { EventEmitter } from "node:events"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AcpRuntimeError } from "../../../../src/acp/runtime/errors.js"; import type { OpenClawConfig } from "../../../../src/config/config.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; - -type NativeCommandSpecMock = { - name: string; - description: string; - acceptsArgs: boolean; -}; - -type PluginCommandSpecMock = { - name: string; - description: string; - acceptsArgs: boolean; -}; - -function baseDiscordAccountConfig() { - return { - commands: { native: true, nativeSkills: false }, - voice: { enabled: false }, - agentComponents: { enabled: false }, - execApprovals: { enabled: false }, - }; -} +import { + baseConfig, + baseRuntime, + getFirstDiscordMessageHandlerParams, + getProviderMonitorTestMocks, + mockResolvedDiscordAccountConfig, + resetDiscordProviderMonitorMocks, +} from "./provider.test-support.js"; const { - clientHandleDeployRequestMock, + clientConstructorOptionsMock, clientFetchUserMock, clientGetPluginMock, - clientConstructorOptionsMock, + clientHandleDeployRequestMock, createDiscordAutoPresenceControllerMock, - createDiscordNativeCommandMock, createDiscordMessageHandlerMock, + createDiscordNativeCommandMock, + createdBindingManagers, createNoopThreadBindingManagerMock, createThreadBindingManagerMock, - reconcileAcpThreadBindingsOnStartupMock, - createdBindingManagers, getAcpSessionStatusMock, getPluginCommandSpecsMock, + isVerboseMock, listNativeCommandSpecsForConfigMock, listSkillCommandsForAgentsMock, monitorLifecycleMock, - resolveDiscordAccountMock, + reconcileAcpThreadBindingsOnStartupMock, resolveDiscordAllowlistConfigMock, + resolveDiscordAccountMock, resolveNativeCommandsEnabledMock, resolveNativeSkillsEnabledMock, - isVerboseMock, shouldLogVerboseMock, voiceRuntimeModuleLoadedMock, -} = vi.hoisted(() => { - const createdBindingManagers: Array<{ stop: ReturnType }> = []; - const isVerboseMock = vi.fn(() => false); - const shouldLogVerboseMock = vi.fn(() => false); - return { - clientHandleDeployRequestMock: vi.fn(async () => undefined), - clientConstructorOptionsMock: vi.fn(), - createDiscordAutoPresenceControllerMock: vi.fn(() => ({ - enabled: false, - start: vi.fn(), - stop: vi.fn(), - refresh: vi.fn(), - runNow: vi.fn(), - })), - clientFetchUserMock: vi.fn(async (_target: string) => ({ id: "bot-1" })), - clientGetPluginMock: vi.fn<(_name: string) => unknown>(() => undefined), - createDiscordNativeCommandMock: vi.fn(() => ({ name: "mock-command" })), - createDiscordMessageHandlerMock: vi.fn(() => - Object.assign( - vi.fn(async () => undefined), - { - deactivate: vi.fn(), - }, - ), - ), - createNoopThreadBindingManagerMock: vi.fn(() => { - const manager = { stop: vi.fn() }; - createdBindingManagers.push(manager); - return manager; - }), - createThreadBindingManagerMock: vi.fn(() => { - const manager = { stop: vi.fn() }; - createdBindingManagers.push(manager); - return manager; - }), - reconcileAcpThreadBindingsOnStartupMock: vi.fn(() => ({ - checked: 0, - removed: 0, - staleSessionKeys: [], - })), - createdBindingManagers, - getAcpSessionStatusMock: vi.fn( - async (_params: { cfg: OpenClawConfig; sessionKey: string; signal?: AbortSignal }) => ({ - state: "idle", - }), - ), - getPluginCommandSpecsMock: vi.fn<() => PluginCommandSpecMock[]>(() => []), - listNativeCommandSpecsForConfigMock: vi.fn<() => NativeCommandSpecMock[]>(() => [ - { name: "cmd", description: "built-in", acceptsArgs: false }, - ]), - listSkillCommandsForAgentsMock: vi.fn(() => []), - monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => { - params.threadBindings.stop(); - }), - 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), - isVerboseMock, - shouldLogVerboseMock, - voiceRuntimeModuleLoadedMock: vi.fn(), - }; -}); - -function mockResolvedDiscordAccountConfig(overrides: Record) { - resolveDiscordAccountMock.mockImplementation(() => ({ - accountId: "default", - token: "cfg-token", - config: { - ...baseDiscordAccountConfig(), - ...overrides, - }, - })); -} - -function getFirstDiscordMessageHandlerParams() { - expect(createDiscordMessageHandlerMock).toHaveBeenCalledTimes(1); - const firstCall = createDiscordMessageHandlerMock.mock.calls.at(0) as [T] | undefined; - return firstCall?.[0]; -} - -vi.mock("@buape/carbon", () => { - class ReadyListener {} - class RateLimitError extends Error { - status = 429; - discordCode?: number; - retryAfter: number; - scope: string | null; - bucket: string | null; - constructor( - response: Response, - body: { message: string; retry_after: number; global: boolean }, - ) { - super(body.message); - this.retryAfter = body.retry_after; - this.scope = body.global ? "global" : response.headers.get("X-RateLimit-Scope"); - this.bucket = response.headers.get("X-RateLimit-Bucket"); - } - } - class Client { - listeners: unknown[]; - rest: { put: ReturnType }; - options: unknown; - constructor(options: unknown, handlers: { listeners?: unknown[] }) { - this.options = options; - this.listeners = handlers.listeners ?? []; - this.rest = { put: vi.fn(async () => undefined) }; - clientConstructorOptionsMock(options); - } - async handleDeployRequest() { - return await clientHandleDeployRequestMock(); - } - async fetchUser(target: string) { - return await clientFetchUserMock(target); - } - getPlugin(name: string) { - return clientGetPluginMock(name); - } - } - return { Client, RateLimitError, ReadyListener }; -}); - -vi.mock("@buape/carbon/gateway", () => ({ - GatewayCloseCodes: { DisallowedIntents: 4014 }, -})); - -vi.mock("@buape/carbon/voice", () => ({ - VoicePlugin: class VoicePlugin {}, -})); - -vi.mock("../../../../src/auto-reply/chunk.js", () => ({ - resolveTextChunkLimit: () => 2000, -})); - -vi.mock("../../../../src/acp/control-plane/manager.js", () => ({ - getAcpSessionManager: () => ({ - getSessionStatus: getAcpSessionStatusMock, - }), -})); - -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: (v: string) => v, - isVerbose: isVerboseMock, - logVerbose: vi.fn(), - shouldLogVerbose: shouldLogVerboseMock, - warn: (v: string) => v, -})); - -vi.mock("../../../../src/infra/errors.js", () => ({ - formatErrorMessage: (err: unknown) => String(err), -})); - -vi.mock("../../../../src/infra/retry-policy.js", () => ({ - createDiscordRetryRunner: () => async (run: () => Promise) => run(), -})); - -vi.mock("../../../../src/logging/subsystem.js", () => ({ - createSubsystemLogger: () => ({ info: vi.fn(), error: vi.fn() }), -})); +} = getProviderMonitorTestMocks(); vi.mock("../../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: getPluginCommandSpecsMock, })); -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("../voice/manager.runtime.js", () => { voiceRuntimeModuleLoadedMock(); return { @@ -266,84 +49,6 @@ vi.mock("../voice/manager.runtime.js", () => { }; }); -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("./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("./auto-presence.js", () => ({ - createDiscordAutoPresenceController: createDiscordAutoPresenceControllerMock, -})); - -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", () => { type ReconcileHealthProbeParams = { cfg: OpenClawConfig; @@ -360,25 +65,6 @@ describe("monitorDiscordProvider", () => { ) => Promise<{ status: string; reason?: string }>; }; - const baseRuntime = (): RuntimeEnv => { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - }; - - const baseConfig = (): OpenClawConfig => - ({ - channels: { - discord: { - accounts: { - default: {}, - }, - }, - }, - }) as OpenClawConfig; - const getConstructedEventQueue = (): { listenerTimeout?: number } | undefined => { expect(clientConstructorOptionsMock).toHaveBeenCalledTimes(1); const opts = clientConstructorOptionsMock.mock.calls[0]?.[0] as { @@ -398,53 +84,7 @@ describe("monitorDiscordProvider", () => { }; beforeEach(() => { - clientHandleDeployRequestMock.mockClear().mockResolvedValue(undefined); - clientConstructorOptionsMock.mockClear(); - createDiscordAutoPresenceControllerMock.mockClear().mockImplementation(() => ({ - enabled: false, - start: vi.fn(), - stop: vi.fn(), - refresh: vi.fn(), - runNow: vi.fn(), - })); - createDiscordMessageHandlerMock.mockClear().mockImplementation(() => - Object.assign( - vi.fn(async () => undefined), - { - deactivate: vi.fn(), - }, - ), - ); - clientFetchUserMock.mockClear().mockResolvedValue({ id: "bot-1" }); - clientGetPluginMock.mockClear().mockReturnValue(undefined); - createDiscordNativeCommandMock.mockClear().mockReturnValue({ name: "mock-command" }); - createNoopThreadBindingManagerMock.mockClear(); - createThreadBindingManagerMock.mockClear(); - reconcileAcpThreadBindingsOnStartupMock.mockClear().mockReturnValue({ - checked: 0, - removed: 0, - staleSessionKeys: [], - }); - getAcpSessionStatusMock.mockClear().mockResolvedValue({ state: "idle" }); - createdBindingManagers.length = 0; - getPluginCommandSpecsMock.mockClear().mockReturnValue([]); - listNativeCommandSpecsForConfigMock - .mockClear() - .mockReturnValue([{ name: "cmd", description: "built-in", acceptsArgs: false }]); - listSkillCommandsForAgentsMock.mockClear().mockReturnValue([]); - monitorLifecycleMock.mockClear().mockImplementation(async (params) => { - params.threadBindings.stop(); - }); - resolveDiscordAccountMock.mockClear(); - resolveDiscordAllowlistConfigMock.mockClear().mockResolvedValue({ - guildEntries: undefined, - allowFrom: undefined, - }); - resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true); - resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false); - isVerboseMock.mockClear().mockReturnValue(false); - shouldLogVerboseMock.mockClear().mockReturnValue(false); - voiceRuntimeModuleLoadedMock.mockClear(); + resetDiscordProviderMonitorMocks(); }); it("stops thread bindings when startup fails before lifecycle begins", async () => { From 06ae5e9d21ea2be50679b5ef8bd07a7dfbc3fb64 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:32:38 +0000 Subject: [PATCH 112/128] refactor(telegram): share native command test menu helpers --- .../bot-native-commands.menu-test-support.ts | 158 ++++++++++++++++++ .../src/bot-native-commands.registry.test.ts | 150 +++-------------- .../telegram/src/bot-native-commands.test.ts | 158 +++++------------- 3 files changed, 227 insertions(+), 239 deletions(-) create mode 100644 extensions/telegram/src/bot-native-commands.menu-test-support.ts diff --git a/extensions/telegram/src/bot-native-commands.menu-test-support.ts b/extensions/telegram/src/bot-native-commands.menu-test-support.ts new file mode 100644 index 00000000000..9af54d3d1bc --- /dev/null +++ b/extensions/telegram/src/bot-native-commands.menu-test-support.ts @@ -0,0 +1,158 @@ +import { expect, vi } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; + +type NativeCommandBot = { + api: { + setMyCommands: ReturnType; + sendMessage: ReturnType; + }; + command: ReturnType; +}; + +type RegisterTelegramNativeCommandsParams = { + bot: NativeCommandBot; + cfg: OpenClawConfig; + runtime: RuntimeEnv; + accountId: string; + telegramCfg: TelegramAccountConfig; + allowFrom: string[]; + groupAllowFrom: string[]; + replyToMode: string; + textLimit: number; + useAccessGroups: boolean; + nativeEnabled: boolean; + nativeSkillsEnabled: boolean; + nativeDisabledExplicit: boolean; + resolveGroupPolicy: () => { allowlistEnabled: boolean; allowed: boolean }; + resolveTelegramGroupConfig: () => { + groupConfig: undefined; + topicConfig: undefined; + }; + shouldSkipUpdate: () => boolean; + opts: { token: string }; +}; + +type RegisteredCommand = { + command: string; + description: string; +}; + +const skillCommandMocks = vi.hoisted(() => ({ + listSkillCommandsForAgents: vi.fn(() => []), +})); + +const deliveryMocks = vi.hoisted(() => ({ + deliverReplies: vi.fn(async () => ({ delivered: true })), +})); + +export const listSkillCommandsForAgents = skillCommandMocks.listSkillCommandsForAgents; +export const deliverReplies = deliveryMocks.deliverReplies; + +vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listSkillCommandsForAgents, + }; +}); + +vi.mock("./bot/delivery.js", () => ({ + deliverReplies, +})); + +export async function waitForRegisteredCommands( + setMyCommands: ReturnType, +): Promise { + await vi.waitFor(() => { + expect(setMyCommands).toHaveBeenCalled(); + }); + return setMyCommands.mock.calls[0]?.[0] as RegisteredCommand[]; +} + +export function resetNativeCommandMenuMocks() { + listSkillCommandsForAgents.mockClear(); + listSkillCommandsForAgents.mockReturnValue([]); + deliverReplies.mockClear(); + deliverReplies.mockResolvedValue({ delivered: true }); +} + +export 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 RegisterTelegramNativeCommandsParams["bot"]; + return { bot, commandHandlers, sendMessage, setMyCommands }; +} + +export function createNativeCommandTestParams( + cfg: OpenClawConfig, + params: Partial = {}, +): RegisterTelegramNativeCommandsParams { + return { + bot: + params.bot ?? + ({ + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn(), + } as unknown as RegisterTelegramNativeCommandsParams["bot"]), + cfg, + runtime: params.runtime ?? ({} as RuntimeEnv), + accountId: params.accountId ?? "default", + telegramCfg: params.telegramCfg ?? ({} as TelegramAccountConfig), + allowFrom: params.allowFrom ?? [], + groupAllowFrom: params.groupAllowFrom ?? [], + replyToMode: params.replyToMode ?? "off", + textLimit: params.textLimit ?? 4000, + useAccessGroups: params.useAccessGroups ?? false, + nativeEnabled: params.nativeEnabled ?? true, + nativeSkillsEnabled: params.nativeSkillsEnabled ?? true, + nativeDisabledExplicit: params.nativeDisabledExplicit ?? false, + resolveGroupPolicy: + params.resolveGroupPolicy ?? + (() => + ({ + allowlistEnabled: false, + allowed: true, + }) as ReturnType), + resolveTelegramGroupConfig: + params.resolveTelegramGroupConfig ?? + (() => ({ + groupConfig: undefined, + topicConfig: undefined, + })), + shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false), + opts: params.opts ?? { token: "token" }, + }; +} + +export function createPrivateCommandContext(params?: { + match?: string; + messageId?: number; + date?: number; + chatId?: number; + userId?: number; + username?: string; +}) { + return { + match: params?.match ?? "", + message: { + message_id: params?.messageId ?? 1, + date: params?.date ?? Math.floor(Date.now() / 1000), + chat: { id: params?.chatId ?? 123, type: "private" as const }, + from: { id: params?.userId ?? 456, username: params?.username ?? "alice" }, + }, + }; +} diff --git a/extensions/telegram/src/bot-native-commands.registry.test.ts b/extensions/telegram/src/bot-native-commands.registry.test.ts index a6fb431c349..c1f9fc1d0a6 100644 --- a/extensions/telegram/src/bot-native-commands.registry.test.ts +++ b/extensions/telegram/src/bot-native-commands.registry.test.ts @@ -1,102 +1,20 @@ 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, -})); +import { + createCommandBot, + createNativeCommandTestParams, + createPrivateCommandContext, + deliverReplies, + resetNativeCommandMenuMocks, + waitForRegisteredCommands, +} from "./bot-native-commands.menu-test-support.js"; 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([]); + resetNativeCommandMenuMocks(); }); afterEach(() => { @@ -117,7 +35,7 @@ describe("registerTelegramNativeCommands real plugin registry", () => { ).toEqual({ ok: true }); registerTelegramNativeCommands({ - ...buildParams({}), + ...createNativeCommandTestParams({}), bot, }); @@ -129,17 +47,9 @@ describe("registerTelegramNativeCommands real plugin registry", () => { 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" }, - }, - }); + await handler?.(createPrivateCommandContext({ match: "now" })); - expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect(deliverReplies).toHaveBeenCalledWith( expect.objectContaining({ replies: [expect.objectContaining({ text: "paired:now" })], }), @@ -165,7 +75,7 @@ describe("registerTelegramNativeCommands real plugin registry", () => { ).toEqual({ ok: true }); registerTelegramNativeCommands({ - ...buildParams({}), + ...createNativeCommandTestParams({}), bot, }); @@ -177,17 +87,9 @@ describe("registerTelegramNativeCommands real plugin registry", () => { const handler = commandHandlers.get("pair_device"); expect(handler).toBeTruthy(); - await handler?.({ - match: "now", - message: { - message_id: 2, - date: Math.floor(Date.now() / 1000), - chat: { id: 123, type: "private" }, - from: { id: 456, username: "alice" }, - }, - }); + await handler?.(createPrivateCommandContext({ match: "now", messageId: 2 })); - expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect(deliverReplies).toHaveBeenCalledWith( expect.objectContaining({ replies: [expect.objectContaining({ text: "paired:now" })], }), @@ -209,7 +111,7 @@ describe("registerTelegramNativeCommands real plugin registry", () => { ).toEqual({ ok: true }); registerTelegramNativeCommands({ - ...buildParams({}, "default"), + ...createNativeCommandTestParams({}, { accountId: "default" }), bot, nativeEnabled: false, }); @@ -232,7 +134,7 @@ describe("registerTelegramNativeCommands real plugin registry", () => { ).toEqual({ ok: true }); registerTelegramNativeCommands({ - ...buildParams({ + ...createNativeCommandTestParams({ commands: { allowFrom: { telegram: ["999"] } } as OpenClawConfig["commands"], }), bot, @@ -245,17 +147,17 @@ describe("registerTelegramNativeCommands real plugin registry", () => { const handler = commandHandlers.get("pair"); expect(handler).toBeTruthy(); - await handler?.({ - match: "now", - message: { - message_id: 10, + await handler?.( + createPrivateCommandContext({ + match: "now", + messageId: 10, date: 123456, - chat: { id: 123, type: "private" }, - from: { id: 111, username: "nope" }, - }, - }); + userId: 111, + username: "nope", + }), + ); - expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect(deliverReplies).toHaveBeenCalledWith( expect.objectContaining({ replies: [expect.objectContaining({ text: "paired:now" })], }), diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index bc843293fc5..6dba343524f 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -6,99 +6,38 @@ import { TELEGRAM_COMMAND_NAME_PATTERN } from "../../../src/config/telegram-cust import type { TelegramAccountConfig } from "../../../src/config/types.js"; import type { RuntimeEnv } from "../../../src/runtime.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; +import { + createCommandBot, + createNativeCommandTestParams, + createPrivateCommandContext, + deliverReplies, + listSkillCommandsForAgents, + resetNativeCommandMenuMocks, + waitForRegisteredCommands, +} from "./bot-native-commands.menu-test-support.js"; -const { listSkillCommandsForAgents } = vi.hoisted(() => ({ - listSkillCommandsForAgents: vi.fn(() => []), -})); const pluginCommandMocks = vi.hoisted(() => ({ getPluginCommandSpecs: vi.fn(() => []), matchPluginCommand: vi.fn(() => null), executePluginCommand: vi.fn(async () => ({ text: "ok" })), })); -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("../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, matchPluginCommand: pluginCommandMocks.matchPluginCommand, executePluginCommand: pluginCommandMocks.executePluginCommand, })); -vi.mock("./bot/delivery.js", () => ({ - deliverReplies: deliveryMocks.deliverReplies, -})); describe("registerTelegramNativeCommands", () => { - type RegisteredCommand = { - command: string; - description: string; - }; - - async function waitForRegisteredCommands( - setMyCommands: ReturnType, - ): Promise { - await vi.waitFor(() => { - expect(setMyCommands).toHaveBeenCalled(); - }); - return setMyCommands.mock.calls[0]?.[0] as RegisteredCommand[]; - } - beforeEach(() => { - listSkillCommandsForAgents.mockClear(); - listSkillCommandsForAgents.mockReturnValue([]); + resetNativeCommandMenuMocks(); pluginCommandMocks.getPluginCommandSpecs.mockClear(); pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([]); pluginCommandMocks.matchPluginCommand.mockClear(); pluginCommandMocks.matchPluginCommand.mockReturnValue(null); pluginCommandMocks.executePluginCommand.mockClear(); pluginCommandMocks.executePluginCommand.mockResolvedValue({ text: "ok" }); - deliveryMocks.deliverReplies.mockClear(); - deliveryMocks.deliverReplies.mockResolvedValue({ delivered: true }); }); - 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]; - it("scopes skill commands when account binding exists", () => { const cfg: OpenClawConfig = { agents: { @@ -112,7 +51,7 @@ describe("registerTelegramNativeCommands", () => { ], }; - registerTelegramNativeCommands(buildParams(cfg, "bot-a")); + registerTelegramNativeCommands(createNativeCommandTestParams(cfg, { accountId: "bot-a" })); expect(listSkillCommandsForAgents).toHaveBeenCalledWith({ cfg, @@ -127,7 +66,7 @@ describe("registerTelegramNativeCommands", () => { }, }; - registerTelegramNativeCommands(buildParams(cfg, "bot-a")); + registerTelegramNativeCommands(createNativeCommandTestParams(cfg, { accountId: "bot-a" })); expect(listSkillCommandsForAgents).toHaveBeenCalledWith({ cfg, @@ -147,7 +86,7 @@ describe("registerTelegramNativeCommands", () => { const runtimeLog = vi.fn(); registerTelegramNativeCommands({ - ...buildParams(cfg), + ...createNativeCommandTestParams(cfg), bot: { api: { setMyCommands, @@ -174,7 +113,7 @@ describe("registerTelegramNativeCommands", () => { const command = vi.fn(); registerTelegramNativeCommands({ - ...buildParams({}), + ...createNativeCommandTestParams({}), bot: { api: { setMyCommands, @@ -202,7 +141,7 @@ describe("registerTelegramNativeCommands", () => { ] as never); registerTelegramNativeCommands({ - ...buildParams({}), + ...createNativeCommandTestParams({}), bot: { api: { setMyCommands, @@ -259,31 +198,24 @@ describe("registerTelegramNativeCommands", () => { } as never); registerTelegramNativeCommands({ - ...buildParams(cfg), - bot: { - api: { - setMyCommands: vi.fn().mockResolvedValue(undefined), - sendMessage, - }, - command: vi.fn((name: string, cb: (ctx: unknown) => Promise) => { - commandHandlers.set(name, cb); - }), - } as unknown as Parameters[0]["bot"], + ...createNativeCommandTestParams(cfg, { + bot: { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage, + }, + command: vi.fn((name: string, cb: (ctx: unknown) => Promise) => { + commandHandlers.set(name, cb); + }), + } as unknown as Parameters[0]["bot"], + }), }); 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" }, - }, - }); + await handler?.(createPrivateCommandContext()); - expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect(deliverReplies).toHaveBeenCalledWith( expect.objectContaining({ mediaLocalRoots: expect.arrayContaining([path.join(STATE_DIR, "workspace-work")]), }), @@ -310,32 +242,28 @@ describe("registerTelegramNativeCommands", () => { } as never); registerTelegramNativeCommands({ - ...buildParams({}), - bot: { - api: { - setMyCommands: vi.fn().mockResolvedValue(undefined), - sendMessage: vi.fn().mockResolvedValue(undefined), + ...createNativeCommandTestParams( + {}, + { + 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"], }, - 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" }, - }, - }); + await handler?.(createPrivateCommandContext()); - expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect(deliverReplies).toHaveBeenCalledWith( expect.objectContaining({ silent: true, replies: [expect.objectContaining({ isError: true })], From 63d82a6299d8eaab5bd2121945b27bb5eec1f3d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:33:41 +0000 Subject: [PATCH 113/128] refactor(telegram): reuse menu helpers in skill allowlist test --- ...t-native-commands.skills-allowlist.test.ts | 67 ++++++------------- 1 file changed, 19 insertions(+), 48 deletions(-) diff --git a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts index c026392f9f9..d15db967767 100644 --- a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts +++ b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts @@ -4,26 +4,24 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { writeSkill } from "../../../src/agents/skills.e2e-test-helpers.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { TelegramAccountConfig } from "../../../src/config/types.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; +import { + createNativeCommandTestParams, + resetNativeCommandMenuMocks, + waitForRegisteredCommands, +} from "./bot-native-commands.menu-test-support.js"; const pluginCommandMocks = vi.hoisted(() => ({ getPluginCommandSpecs: vi.fn(() => []), matchPluginCommand: vi.fn(() => null), executePluginCommand: vi.fn(async () => ({ text: "ok" })), })); -const deliveryMocks = vi.hoisted(() => ({ - deliverReplies: vi.fn(async () => ({ delivered: true })), -})); vi.mock("../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, matchPluginCommand: pluginCommandMocks.matchPluginCommand, executePluginCommand: pluginCommandMocks.executePluginCommand, })); -vi.mock("./bot/delivery.js", () => ({ - deliverReplies: deliveryMocks.deliverReplies, -})); const tempDirs: string[] = []; @@ -35,10 +33,10 @@ async function makeWorkspace(prefix: string) { describe("registerTelegramNativeCommands skill allowlist integration", () => { afterEach(async () => { + resetNativeCommandMenuMocks(); pluginCommandMocks.getPluginCommandSpecs.mockClear().mockReturnValue([]); pluginCommandMocks.matchPluginCommand.mockClear().mockReturnValue(null); pluginCommandMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" }); - deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true }); await Promise.all( tempDirs .splice(0, tempDirs.length) @@ -76,49 +74,22 @@ describe("registerTelegramNativeCommands skill allowlist integration", () => { }; registerTelegramNativeCommands({ - bot: { - api: { - setMyCommands, - sendMessage: vi.fn().mockResolvedValue(undefined), - }, - command: vi.fn(), - } as unknown as Parameters[0]["bot"], - cfg, - runtime: { log: vi.fn() } as unknown as Parameters< - typeof registerTelegramNativeCommands - >[0]["runtime"], - accountId: "bot-a", - 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, + ...createNativeCommandTestParams(cfg, { + bot: { + api: { + setMyCommands, + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn(), + } as unknown as Parameters[0]["bot"], + runtime: { log: vi.fn() } as unknown as Parameters< + typeof registerTelegramNativeCommands + >[0]["runtime"], + accountId: "bot-a", }), - shouldSkipUpdate: () => false, - opts: { token: "token" }, }); - await vi.waitFor(() => { - expect(setMyCommands).toHaveBeenCalled(); - }); - const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{ - command: string; - description: string; - }>; + const registeredCommands = await waitForRegisteredCommands(setMyCommands); expect(registeredCommands.some((entry) => entry.command === "alpha_skill")).toBe(true); expect(registeredCommands.some((entry) => entry.command === "beta_skill")).toBe(false); From 5ce2ed3bd274fa32983b55638bcc7fa6a19a7b02 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:36:18 +0000 Subject: [PATCH 114/128] refactor(telegram): share native command test fixtures --- ...ot-native-commands.fixture-test-support.ts | 128 ++++++++++++++++++ .../bot-native-commands.menu-test-support.ts | 96 ++----------- .../bot-native-commands.session-meta.test.ts | 119 +++------------- .../src/bot-native-commands.test-helpers.ts | 50 +------ 4 files changed, 166 insertions(+), 227 deletions(-) create mode 100644 extensions/telegram/src/bot-native-commands.fixture-test-support.ts diff --git a/extensions/telegram/src/bot-native-commands.fixture-test-support.ts b/extensions/telegram/src/bot-native-commands.fixture-test-support.ts new file mode 100644 index 00000000000..ab2439d65ec --- /dev/null +++ b/extensions/telegram/src/bot-native-commands.fixture-test-support.ts @@ -0,0 +1,128 @@ +import { vi } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; + +export type NativeCommandTestParams = { + bot: { + api: { + setMyCommands: ReturnType; + sendMessage: ReturnType; + }; + command: ReturnType; + }; + cfg: OpenClawConfig; + runtime: RuntimeEnv; + accountId: string; + telegramCfg: TelegramAccountConfig; + allowFrom: string[]; + groupAllowFrom: string[]; + replyToMode: string; + textLimit: number; + useAccessGroups: boolean; + nativeEnabled: boolean; + nativeSkillsEnabled: boolean; + nativeDisabledExplicit: boolean; + resolveGroupPolicy: () => { allowlistEnabled: boolean; allowed: boolean }; + resolveTelegramGroupConfig: () => { + groupConfig: undefined; + topicConfig: undefined; + }; + shouldSkipUpdate: () => boolean; + opts: { token: string }; +}; + +export function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +export function createNativeCommandTestParams( + params: Partial = {}, +): NativeCommandTestParams { + const log = vi.fn(); + return { + bot: + params.bot ?? + ({ + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn(), + } as NativeCommandTestParams["bot"]), + cfg: params.cfg ?? ({} as OpenClawConfig), + runtime: params.runtime ?? ({ log } as RuntimeEnv), + accountId: params.accountId ?? "default", + telegramCfg: params.telegramCfg ?? ({} as TelegramAccountConfig), + allowFrom: params.allowFrom ?? [], + groupAllowFrom: params.groupAllowFrom ?? [], + replyToMode: params.replyToMode ?? "off", + textLimit: params.textLimit ?? 4000, + useAccessGroups: params.useAccessGroups ?? false, + nativeEnabled: params.nativeEnabled ?? true, + nativeSkillsEnabled: params.nativeSkillsEnabled ?? false, + nativeDisabledExplicit: params.nativeDisabledExplicit ?? false, + resolveGroupPolicy: + params.resolveGroupPolicy ?? + (() => + ({ + allowlistEnabled: false, + allowed: true, + }) as ReturnType), + resolveTelegramGroupConfig: + params.resolveTelegramGroupConfig ?? + (() => ({ groupConfig: undefined, topicConfig: undefined })), + shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false), + opts: params.opts ?? { token: "token" }, + }; +} + +export function createTelegramPrivateCommandContext(params?: { + match?: string; + messageId?: number; + date?: number; + chatId?: number; + userId?: number; + username?: string; +}) { + return { + match: params?.match ?? "", + message: { + message_id: params?.messageId ?? 1, + date: params?.date ?? Math.floor(Date.now() / 1000), + chat: { id: params?.chatId ?? 100, type: "private" as const }, + from: { id: params?.userId ?? 200, username: params?.username ?? "bob" }, + }, + }; +} + +export function createTelegramTopicCommandContext(params?: { + match?: string; + messageId?: number; + date?: number; + chatId?: number; + title?: string; + threadId?: number; + userId?: number; + username?: string; +}) { + return { + match: params?.match ?? "", + message: { + message_id: params?.messageId ?? 2, + date: params?.date ?? Math.floor(Date.now() / 1000), + chat: { + id: params?.chatId ?? -1001234567890, + type: "supergroup" as const, + title: params?.title ?? "OpenClaw", + is_forum: true, + }, + message_thread_id: params?.threadId ?? 42, + from: { id: params?.userId ?? 200, username: params?.username ?? "bob" }, + }, + }; +} diff --git a/extensions/telegram/src/bot-native-commands.menu-test-support.ts b/extensions/telegram/src/bot-native-commands.menu-test-support.ts index 9af54d3d1bc..241c50ac6be 100644 --- a/extensions/telegram/src/bot-native-commands.menu-test-support.ts +++ b/extensions/telegram/src/bot-native-commands.menu-test-support.ts @@ -1,38 +1,11 @@ import { expect, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { TelegramAccountConfig } from "../../../src/config/types.js"; import type { RuntimeEnv } from "../../../src/runtime.js"; - -type NativeCommandBot = { - api: { - setMyCommands: ReturnType; - sendMessage: ReturnType; - }; - command: ReturnType; -}; - -type RegisterTelegramNativeCommandsParams = { - bot: NativeCommandBot; - cfg: OpenClawConfig; - runtime: RuntimeEnv; - accountId: string; - telegramCfg: TelegramAccountConfig; - allowFrom: string[]; - groupAllowFrom: string[]; - replyToMode: string; - textLimit: number; - useAccessGroups: boolean; - nativeEnabled: boolean; - nativeSkillsEnabled: boolean; - nativeDisabledExplicit: boolean; - resolveGroupPolicy: () => { allowlistEnabled: boolean; allowed: boolean }; - resolveTelegramGroupConfig: () => { - groupConfig: undefined; - topicConfig: undefined; - }; - shouldSkipUpdate: () => boolean; - opts: { token: string }; -}; +import { + createNativeCommandTestParams as createBaseNativeCommandTestParams, + createTelegramPrivateCommandContext, + type NativeCommandTestParams as RegisterTelegramNativeCommandsParams, +} from "./bot-native-commands.fixture-test-support.js"; type RegisteredCommand = { command: string; @@ -98,61 +71,12 @@ export function createNativeCommandTestParams( cfg: OpenClawConfig, params: Partial = {}, ): RegisterTelegramNativeCommandsParams { - return { - bot: - params.bot ?? - ({ - api: { - setMyCommands: vi.fn().mockResolvedValue(undefined), - sendMessage: vi.fn().mockResolvedValue(undefined), - }, - command: vi.fn(), - } as unknown as RegisterTelegramNativeCommandsParams["bot"]), + return createBaseNativeCommandTestParams({ cfg, runtime: params.runtime ?? ({} as RuntimeEnv), - accountId: params.accountId ?? "default", - telegramCfg: params.telegramCfg ?? ({} as TelegramAccountConfig), - allowFrom: params.allowFrom ?? [], - groupAllowFrom: params.groupAllowFrom ?? [], - replyToMode: params.replyToMode ?? "off", - textLimit: params.textLimit ?? 4000, - useAccessGroups: params.useAccessGroups ?? false, - nativeEnabled: params.nativeEnabled ?? true, - nativeSkillsEnabled: params.nativeSkillsEnabled ?? true, - nativeDisabledExplicit: params.nativeDisabledExplicit ?? false, - resolveGroupPolicy: - params.resolveGroupPolicy ?? - (() => - ({ - allowlistEnabled: false, - allowed: true, - }) as ReturnType), - resolveTelegramGroupConfig: - params.resolveTelegramGroupConfig ?? - (() => ({ - groupConfig: undefined, - topicConfig: undefined, - })), - shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false), - opts: params.opts ?? { token: "token" }, - }; + nativeSkillsEnabled: true, + ...params, + }); } -export function createPrivateCommandContext(params?: { - match?: string; - messageId?: number; - date?: number; - chatId?: number; - userId?: number; - username?: string; -}) { - return { - match: params?.match ?? "", - message: { - message_id: params?.messageId ?? 1, - date: params?.date ?? Math.floor(Date.now() / 1000), - chat: { id: params?.chatId ?? 123, type: "private" as const }, - from: { id: params?.userId ?? 456, username: params?.username ?? "alice" }, - }, - }; -} +export { createTelegramPrivateCommandContext as createPrivateCommandContext }; 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 6160afccf01..0a75b12fc1a 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -1,12 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; +import { + createDeferred, + createNativeCommandTestParams, + createTelegramPrivateCommandContext, + createTelegramTopicCommandContext, + type NativeCommandTestParams, +} from "./bot-native-commands.fixture-test-support.js"; import { registerTelegramNativeCommands, type RegisterTelegramHandlerParams, } from "./bot-native-commands.js"; -type RegisterTelegramNativeCommandsParams = Parameters[0]; - // All mocks scoped to this file only — does not affect bot-native-commands.test.ts type ResolveConfiguredAcpBindingRecordFn = @@ -101,93 +106,13 @@ vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies, })); -function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - const promise = new Promise((res) => { - resolve = res; - }); - return { promise, resolve }; -} - -function createNativeCommandTestParams( - params: Partial = {}, -): RegisterTelegramNativeCommandsParams { - const log = vi.fn(); - return { - bot: - params.bot ?? - ({ - api: { - setMyCommands: vi.fn().mockResolvedValue(undefined), - sendMessage: vi.fn().mockResolvedValue(undefined), - }, - command: vi.fn(), - } as unknown as RegisterTelegramNativeCommandsParams["bot"]), - cfg: params.cfg ?? ({} as OpenClawConfig), - runtime: - params.runtime ?? ({ log } as unknown as RegisterTelegramNativeCommandsParams["runtime"]), - accountId: params.accountId ?? "default", - telegramCfg: params.telegramCfg ?? ({} as RegisterTelegramNativeCommandsParams["telegramCfg"]), - allowFrom: params.allowFrom ?? [], - groupAllowFrom: params.groupAllowFrom ?? [], - replyToMode: params.replyToMode ?? "off", - textLimit: params.textLimit ?? 4000, - useAccessGroups: params.useAccessGroups ?? false, - nativeEnabled: params.nativeEnabled ?? true, - nativeSkillsEnabled: params.nativeSkillsEnabled ?? false, - nativeDisabledExplicit: params.nativeDisabledExplicit ?? false, - resolveGroupPolicy: - params.resolveGroupPolicy ?? - (() => - ({ - allowlistEnabled: false, - allowed: true, - }) as ReturnType), - resolveTelegramGroupConfig: - params.resolveTelegramGroupConfig ?? - (() => ({ groupConfig: undefined, topicConfig: undefined })), - shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false), - opts: params.opts ?? { token: "token" }, - }; -} - type TelegramCommandHandler = (ctx: unknown) => Promise; -function buildStatusCommandContext() { - return { - match: "", - message: { - message_id: 1, - date: Math.floor(Date.now() / 1000), - chat: { id: 100, type: "private" as const }, - from: { id: 200, username: "bob" }, - }, - }; -} - -function buildStatusTopicCommandContext() { - return { - match: "", - message: { - message_id: 2, - date: Math.floor(Date.now() / 1000), - chat: { - id: -1001234567890, - type: "supergroup" as const, - title: "OpenClaw", - is_forum: true, - }, - message_thread_id: 42, - from: { id: 200, username: "bob" }, - }, - }; -} - function registerAndResolveStatusHandler(params: { cfg: OpenClawConfig; allowFrom?: string[]; groupAllowFrom?: string[]; - telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"]; + telegramCfg?: NativeCommandTestParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; @@ -211,7 +136,7 @@ function registerAndResolveCommandHandlerBase(params: { allowFrom: string[]; groupAllowFrom: string[]; useAccessGroups: boolean; - telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"]; + telegramCfg?: NativeCommandTestParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; @@ -238,7 +163,7 @@ function registerAndResolveCommandHandlerBase(params: { command: vi.fn((name: string, cb: TelegramCommandHandler) => { commandHandlers.set(name, cb); }), - } as unknown as Parameters[0]["bot"], + } as unknown as NativeCommandTestParams["bot"], cfg, allowFrom, groupAllowFrom, @@ -259,7 +184,7 @@ function registerAndResolveCommandHandler(params: { allowFrom?: string[]; groupAllowFrom?: string[]; useAccessGroups?: boolean; - telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"]; + telegramCfg?: NativeCommandTestParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; @@ -344,7 +269,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("calls recordSessionMetaFromInbound after a native slash command", async () => { const cfg: OpenClawConfig = {}; const { handler } = registerAndResolveStatusHandler({ cfg }); - await handler(buildStatusCommandContext()); + await handler(createTelegramPrivateCommandContext()); expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1); const call = ( @@ -363,7 +288,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { const cfg: OpenClawConfig = {}; const { handler } = registerAndResolveStatusHandler({ cfg }); - const runPromise = handler(buildStatusCommandContext()); + const runPromise = handler(createTelegramPrivateCommandContext()); await vi.waitFor(() => { expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1); @@ -402,7 +327,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { }, }, }); - await handler(buildStatusCommandContext()); + await handler(createTelegramPrivateCommandContext()); const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as | DeliverRepliesParams @@ -446,7 +371,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { }, }, }); - await handler(buildStatusCommandContext()); + await handler(createTelegramPrivateCommandContext()); expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); }); @@ -463,7 +388,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { cfg: {}, telegramCfg: { silentErrorReplies: true }, }); - await handler(buildStatusCommandContext()); + await handler(createTelegramPrivateCommandContext()); const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as | DeliverRepliesParams @@ -491,7 +416,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { allowFrom: ["200"], groupAllowFrom: ["200"], }); - await handler(buildStatusTopicCommandContext()); + await handler(createTelegramTopicCommandContext()); expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); @@ -519,7 +444,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { topicConfig: { agentId: "zu" }, }), }); - await handler(buildStatusTopicCommandContext()); + await handler(createTelegramTopicCommandContext()); const dispatchCall = ( replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< @@ -542,7 +467,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { allowFrom: ["200"], groupAllowFrom: ["200"], }); - await handler(buildStatusTopicCommandContext()); + await handler(createTelegramTopicCommandContext()); expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({ channel: "telegram", @@ -577,7 +502,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { allowFrom: ["200"], groupAllowFrom: ["200"], }); - await handler(buildStatusTopicCommandContext()); + await handler(createTelegramTopicCommandContext()); expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(sendMessage).toHaveBeenCalledWith( @@ -604,7 +529,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { groupAllowFrom: [], useAccessGroups: true, }); - await handler(buildStatusTopicCommandContext()); + await handler(createTelegramTopicCommandContext()); expectUnauthorizedNewCommandBlocked(sendMessage); }); @@ -619,7 +544,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { groupAllowFrom: [], useAccessGroups: true, }); - await handler(buildStatusTopicCommandContext()); + await handler(createTelegramTopicCommandContext()); expectUnauthorizedNewCommandBlocked(sendMessage); }); diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index a39bdd23da6..f443040b17d 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -4,9 +4,12 @@ import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { MockFn } from "openclaw/plugin-sdk/test-utils"; import { vi } from "vitest"; +import { + createNativeCommandTestParams, + type NativeCommandTestParams, +} from "./bot-native-commands.fixture-test-support.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; -type RegisterTelegramNativeCommandsParams = Parameters[0]; type GetPluginCommandSpecsFn = typeof import("openclaw/plugin-sdk/plugin-runtime").getPluginCommandSpecs; type MatchPluginCommandFn = typeof import("openclaw/plugin-sdk/plugin-runtime").matchPluginCommand; @@ -89,48 +92,7 @@ vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverRepli vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ readChannelAllowFromStore: vi.fn(async () => []), })); - -export function createNativeCommandTestParams( - params: Partial = {}, -): RegisterTelegramNativeCommandsParams { - const log = vi.fn(); - return { - bot: - params.bot ?? - ({ - api: { - setMyCommands: vi.fn().mockResolvedValue(undefined), - sendMessage: vi.fn().mockResolvedValue(undefined), - }, - command: vi.fn(), - } as unknown as RegisterTelegramNativeCommandsParams["bot"]), - cfg: params.cfg ?? ({} as OpenClawConfig), - runtime: - params.runtime ?? ({ log } as unknown as RegisterTelegramNativeCommandsParams["runtime"]), - accountId: params.accountId ?? "default", - telegramCfg: params.telegramCfg ?? ({} as RegisterTelegramNativeCommandsParams["telegramCfg"]), - allowFrom: params.allowFrom ?? [], - groupAllowFrom: params.groupAllowFrom ?? [], - replyToMode: params.replyToMode ?? "off", - textLimit: params.textLimit ?? 4000, - useAccessGroups: params.useAccessGroups ?? false, - nativeEnabled: params.nativeEnabled ?? true, - nativeSkillsEnabled: params.nativeSkillsEnabled ?? false, - nativeDisabledExplicit: params.nativeDisabledExplicit ?? false, - resolveGroupPolicy: - params.resolveGroupPolicy ?? - (() => - ({ - allowlistEnabled: false, - allowed: true, - }) as ReturnType), - resolveTelegramGroupConfig: - params.resolveTelegramGroupConfig ?? - (() => ({ groupConfig: undefined, topicConfig: undefined })), - shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false), - opts: params.opts ?? { token: "token" }, - }; -} +export { createNativeCommandTestParams }; export function createNativeCommandsHarness(params?: { cfg?: OpenClawConfig; @@ -158,7 +120,7 @@ export function createNativeCommandsHarness(params?: { } as const; registerTelegramNativeCommands({ - bot: bot as unknown as Parameters[0]["bot"], + bot: bot as unknown as NativeCommandTestParams["bot"], cfg: params?.cfg ?? ({} as OpenClawConfig), runtime: params?.runtime ?? ({ log } as unknown as RuntimeEnv), accountId: "default", From 7ab074631b5328385226ec50535b219fca7a6d69 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:38:07 +0000 Subject: [PATCH 115/128] refactor(setup): share allowlist wizard proxies --- extensions/discord/src/setup-core.ts | 12 +++++ extensions/slack/src/setup-core.ts | 11 ++++ src/channels/plugins/setup-wizard-proxy.ts | 61 ++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 src/channels/plugins/setup-wizard-proxy.ts diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index 7cdf9aa2434..f9a9d95df4b 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,3 +1,5 @@ +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; +import { createAllowlistSetupWizardProxy } from "../../../src/channels/plugins/setup-wizard-proxy.js"; import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; import { applyAccountNameToChannelSection, @@ -347,3 +349,13 @@ export function createDiscordSetupWizardProxy( disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } +export function createDiscordSetupWizardProxy( + loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, +) { + return createAllowlistSetupWizardProxy({ + loadWizard: async () => (await loadWizard()).discordSetupWizard, + createBase: createDiscordSetupWizardBase, + fallbackResolvedGroupAllowlist: (entries) => + entries.map((input) => ({ input, resolved: false })), + }); +} diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 8fc53239c81..0b4c63c8b70 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,3 +1,5 @@ +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; +import { createAllowlistSetupWizardProxy } from "../../../src/channels/plugins/setup-wizard-proxy.js"; import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, @@ -390,3 +392,12 @@ export function createSlackSetupWizardProxy( disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } +export function createSlackSetupWizardProxy( + loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, +) { + return createAllowlistSetupWizardProxy({ + loadWizard: async () => (await loadWizard()).slackSetupWizard, + createBase: createSlackSetupWizardBase, + fallbackResolvedGroupAllowlist: (entries) => entries, + }); +} diff --git a/src/channels/plugins/setup-wizard-proxy.ts b/src/channels/plugins/setup-wizard-proxy.ts new file mode 100644 index 00000000000..195254374cb --- /dev/null +++ b/src/channels/plugins/setup-wizard-proxy.ts @@ -0,0 +1,61 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { ChannelSetupDmPolicy } from "./setup-wizard-types.js"; +import type { ChannelSetupWizard } from "./setup-wizard.js"; + +type PromptAllowFromParams = Parameters>[0]; +type ResolveAllowFromEntriesParams = Parameters< + NonNullable["resolveEntries"] +>[0]; +type ResolveAllowFromEntriesResult = Awaited< + ReturnType["resolveEntries"]> +>; +type ResolveGroupAllowlistParams = Parameters< + NonNullable["resolveAllowlist"]> +>[0]; + +export function createAllowlistSetupWizardProxy(params: { + loadWizard: () => Promise; + createBase: (handlers: { + promptAllowFrom: (params: PromptAllowFromParams) => Promise; + resolveAllowFromEntries: ( + params: ResolveAllowFromEntriesParams, + ) => Promise; + resolveGroupAllowlist: (params: ResolveGroupAllowlistParams) => Promise; + }) => ChannelSetupWizard; + fallbackResolvedGroupAllowlist: (entries: string[]) => TGroupResolved; +}) { + return params.createBase({ + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const wizard = await params.loadWizard(); + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, + resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => { + const wizard = await params.loadWizard(); + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, + resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + const wizard = await params.loadWizard(); + if (!wizard.groupAccess?.resolveAllowlist) { + return params.fallbackResolvedGroupAllowlist(entries); + } + return (await wizard.groupAccess.resolveAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + })) as TGroupResolved; + }, + }); +} From 23deb3da98a9c20b2799d0ce712f9f724fc95b2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:41:39 +0000 Subject: [PATCH 116/128] refactor(discord): share native command plugin test setup --- .../native-command.plugin-dispatch.test.ts | 165 ++++++++---------- 1 file changed, 77 insertions(+), 88 deletions(-) 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 dc81bc72e00..4541ee3ab9d 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -80,6 +80,71 @@ function createStatusCommand(cfg: OpenClawConfig) { }); } +function createPluginCommand(params: { cfg: OpenClawConfig; name: string }) { + return createDiscordNativeCommand({ + command: { + name: params.name, + description: "Pair", + acceptsArgs: true, + } satisfies NativeCommandSpec, + cfg: params.cfg, + discordConfig: params.cfg.channels?.discord ?? {}, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); +} + +function registerPairPlugin(params?: { discordNativeName?: string }) { + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + ...(params?.discordNativeName + ? { + nativeNames: { + telegram: "pair_device", + discord: params.discordNativeName, + }, + } + : {}), + description: "Pair device", + acceptsArgs: true, + requireAuth: false, + handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), + }), + ).toEqual({ ok: true }); +} + +async function expectPairCommandReply(params: { + cfg: OpenClawConfig; + commandName: string; + interaction: MockCommandInteraction; +}) { + const command = createPluginCommand({ + cfg: params.cfg, + name: params.commandName, + }); + const dispatchSpy = vi + .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") + .mockResolvedValue({} as never); + + await (command as { run: (interaction: unknown) => Promise }).run( + Object.assign(params.interaction, { + options: { + getString: () => "now", + getBoolean: () => null, + getFocused: () => "", + }, + }) as unknown, + ); + + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(params.interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: "paired:now" }), + ); +} + function setConfiguredBinding(channelId: string, boundSessionKey: string) { persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ spec: { @@ -166,102 +231,26 @@ 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" }), - ); + registerPairPlugin(); + await expectPairCommandReply({ + cfg, + commandName: "pair", + interaction, + }); }); it("round-trips Discord native aliases through the real plugin registry", async () => { const cfg = createConfig(); - const commandSpec: NativeCommandSpec = { - name: "pairdiscord", - 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", - nativeNames: { - telegram: "pair_device", - discord: "pairdiscord", - }, - 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" }), - ); + registerPairPlugin({ discordNativeName: "pairdiscord" }); + await expectPairCommandReply({ + cfg, + commandName: "pairdiscord", + interaction, + }); }); it("blocks unauthorized Discord senders before requireAuth:false plugin commands execute", async () => { From 1dc3104dbf8fec8726f141f04f1f3f5a34b61052 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:48:37 +0000 Subject: [PATCH 117/128] fix(channels): restore shared module imports --- extensions/slack/src/channel.setup.ts | 8 ++------ extensions/slack/src/channel.ts | 8 ++------ extensions/telegram/src/channel.setup.ts | 8 ++++---- extensions/telegram/src/channel.ts | 12 ++++++------ 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index 0eaf3053aa2..2fbaac93ab6 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -6,13 +6,9 @@ import { } from "openclaw/plugin-sdk/slack"; import { type ResolvedSlackAccount } from "./accounts.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; -import { - isSlackPluginAccountConfigured, - slackConfigAccessors, - slackConfigBase, - slackSetupWizard, -} from "./plugin-shared.js"; import { slackSetupAdapter } from "./setup-core.js"; +import { slackSetupWizard } from "./setup-surface.js"; +import { isSlackPluginAccountConfigured, slackConfigAccessors, slackConfigBase } from "./shared.js"; export const slackSetupPlugin: ChannelPlugin = { id: "slack", diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 4890ab88eaa..cdddeadebfe 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -44,17 +44,13 @@ import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { handleSlackMessageAction } from "./message-action-dispatch.js"; import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; -import { - isSlackPluginAccountConfigured, - slackConfigAccessors, - slackConfigBase, - slackSetupWizard, -} from "./plugin-shared.js"; import type { SlackProbe } from "./probe.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { getSlackRuntime } from "./runtime.js"; import { fetchSlackScopes } from "./scopes.js"; import { slackSetupAdapter } from "./setup-core.js"; +import { slackSetupWizard } from "./setup-surface.js"; +import { isSlackPluginAccountConfigured, slackConfigAccessors, slackConfigBase } from "./shared.js"; import { parseSlackTarget } from "./targets.js"; import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index 1da52dbe885..09b41cddb0f 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -5,15 +5,15 @@ import { type ChannelPlugin, } from "openclaw/plugin-sdk/telegram"; import { type ResolvedTelegramAccount } from "./accounts.js"; +import type { TelegramProbe } from "./probe.js"; +import { telegramSetupAdapter } from "./setup-core.js"; +import { telegramSetupWizard } from "./setup-surface.js"; import { findTelegramTokenOwnerAccountId, formatDuplicateTelegramTokenReason, telegramConfigAccessors, telegramConfigBase, -} from "./plugin-shared.js"; -import type { TelegramProbe } from "./probe.js"; -import { telegramSetupAdapter } from "./setup-core.js"; -import { telegramSetupWizard } from "./setup-surface.js"; +} from "./shared.js"; export const telegramSetupPlugin: ChannelPlugin = { id: "telegram", diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index e157ea34ba7..0054aaba6de 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -48,17 +48,17 @@ 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 { - findTelegramTokenOwnerAccountId, - formatDuplicateTelegramTokenReason, - telegramConfigAccessors, - telegramConfigBase, -} from "./plugin-shared.js"; import { probeTelegram, type TelegramProbe } from "./probe.js"; import { getTelegramRuntime } from "./runtime.js"; import { sendTypingTelegram } from "./send.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; +import { + findTelegramTokenOwnerAccountId, + formatDuplicateTelegramTokenReason, + telegramConfigAccessors, + telegramConfigBase, +} from "./shared.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; From 503932919fc1075075dc6252a60234c1031d1f08 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:48:44 +0000 Subject: [PATCH 118/128] refactor(sandbox): share fs bridge path helpers --- src/agents/sandbox/remote-fs-bridge.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/agents/sandbox/remote-fs-bridge.ts b/src/agents/sandbox/remote-fs-bridge.ts index ef70e928eac..878bdacc3c3 100644 --- a/src/agents/sandbox/remote-fs-bridge.ts +++ b/src/agents/sandbox/remote-fs-bridge.ts @@ -1,7 +1,12 @@ import path from "node:path"; +import { isPathInside } from "../../infra/path-guards.js"; import type { SandboxBackendCommandParams, SandboxBackendCommandResult } from "./backend.js"; import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js"; import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./fs-bridge.js"; +import { + isPathInsideContainerRoot, + normalizeContainerPath as normalizeSandboxContainerPath, +} from "./path-utils.js"; import type { SandboxContext } from "./types.js"; type ResolvedRemotePath = SandboxResolvedPath & { @@ -496,23 +501,10 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge { } function normalizeContainerPath(value: string): string { - const normalized = path.posix.normalize(value.trim() || "/"); + const normalized = normalizeSandboxContainerPath(value.trim() || "/"); return normalized.startsWith("/") ? normalized : `/${normalized}`; } -function isPathInsideContainerRoot(root: string, candidate: string): boolean { - const normalizedRoot = normalizeContainerPath(root); - const normalizedCandidate = normalizeContainerPath(candidate); - return ( - normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) - ); -} - -function isPathInside(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - function toPosixRelative(root: string, candidate: string): string { return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep); } From 7e9c46d7dd7f8a0070c99e1ab089091903d8cb17 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:50:59 +0000 Subject: [PATCH 119/128] refactor(whatsapp): share plugin base config --- extensions/whatsapp/src/channel.setup.ts | 151 +-------------------- extensions/whatsapp/src/channel.ts | 163 +++-------------------- extensions/whatsapp/src/plugin-shared.ts | 51 ------- extensions/whatsapp/src/shared.ts | 8 ++ 4 files changed, 30 insertions(+), 343 deletions(-) delete mode 100644 extensions/whatsapp/src/plugin-shared.ts diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index 6cf2a75d1ce..ebe4deb5789 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -1,150 +1,13 @@ -import { - buildAccountScopedDmSecurityPolicy, - buildChannelConfigSchema, - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, - DEFAULT_ACCOUNT_ID, - formatWhatsAppConfigAllowFromEntries, - getChatChannelMeta, - normalizeE164, - resolveWhatsAppConfigAllowFrom, - resolveWhatsAppConfigDefaultTo, - resolveWhatsAppGroupIntroHint, - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupToolPolicy, - WhatsAppConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/whatsapp"; -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAccount, - type ResolvedWhatsAppAccount, -} from "./accounts.js"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/whatsapp"; +import { type ResolvedWhatsAppAccount } from "./accounts.js"; import { webAuthExists } from "./auth-store.js"; -import { whatsappSetupWizardProxy } from "./plugin-shared.js"; import { whatsappSetupAdapter } from "./setup-core.js"; +import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js"; export const whatsappSetupPlugin: ChannelPlugin = { - id: "whatsapp", - meta: { - ...getChatChannelMeta("whatsapp"), - showConfigured: false, - quickstartAllowFrom: true, - forceAccountBinding: true, - preferSessionLookupForAnnounceTarget: true, - }, - setupWizard: whatsappSetupWizardProxy, - capabilities: { - chatTypes: ["direct", "group"], - polls: true, - reactions: true, - media: true, - }, - reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, - gatewayMethods: ["web.login.start", "web.login.wait"], - configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), - config: { - listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - const existing = accounts[accountKey] ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: { - ...accounts, - [accountKey]: { - ...existing, - enabled, - }, - }, - }, - }, - }; - }, - deleteAccount: ({ cfg, accountId }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - delete accounts[accountKey]; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: Object.keys(accounts).length ? accounts : undefined, - }, - }, - }; - }, - isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, - disabledReason: () => "disabled", + ...createWhatsAppPluginBase({ + setupWizard: whatsappSetupWizardProxy, + setup: whatsappSetupAdapter, isConfigured: async (account) => await webAuthExists(account.authDir), - unconfiguredReason: () => "not linked", - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.authDir), - linked: Boolean(account.authDir), - dmPolicy: account.dmPolicy, - allowFrom: account.allowFrom, - }), - resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => - buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "whatsapp", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.dmPolicy, - allowFrom: account.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw), - }), - collectWarnings: ({ account, cfg }) => { - const groupAllowlistConfigured = - Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.whatsapp !== undefined, - configuredGroupPolicy: account.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: groupAllowlistConfigured, - restrictSenders: { - surface: "WhatsApp groups", - openScope: "any member in allowed groups", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "WhatsApp groups", - routeAllowlistPath: "channels.whatsapp.groups", - routeScope: "group", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - }), - }); - }, - }, - setup: whatsappSetupAdapter, - groups: { - resolveRequireMention: resolveWhatsAppGroupRequireMention, - resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, - }, + }), }; diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index dda6215c27f..3bf9bba0c34 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,46 +1,30 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - buildAccountScopedDmSecurityPolicy, - buildChannelConfigSchema, - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, createActionGate, createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, - getChatChannelMeta, listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, - normalizeE164, - formatWhatsAppConfigAllowFromEntries, readStringParam, resolveWhatsAppOutboundTarget, - resolveWhatsAppConfigAllowFrom, - resolveWhatsAppConfigDefaultTo, - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupIntroHint, - resolveWhatsAppGroupToolPolicy, resolveWhatsAppHeartbeatRecipients, resolveWhatsAppMentionStripRegexes, - WhatsAppConfigSchema, type ChannelMessageActionName, type ChannelPlugin, } from "openclaw/plugin-sdk/whatsapp"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "openclaw/plugin-sdk/whatsapp"; // WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAccount, - type ResolvedWhatsAppAccount, -} from "./accounts.js"; +import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; -import { loadWhatsAppChannelRuntime, whatsappSetupWizardProxy } from "./plugin-shared.js"; import { getWhatsAppRuntime } from "./runtime.js"; import { whatsappSetupAdapter } from "./setup-core.js"; +import { + createWhatsAppPluginBase, + loadWhatsAppChannelRuntime, + whatsappSetupWizardProxy, + WHATSAPP_CHANNEL, +} from "./shared.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; - -const meta = getChatChannelMeta("whatsapp"); - function normalizeWhatsAppPayloadText(text: string | undefined): string { return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); } @@ -57,86 +41,16 @@ function parseWhatsAppExplicitTarget(raw: string) { } export const whatsappPlugin: ChannelPlugin = { - id: "whatsapp", - meta: { - ...meta, - showConfigured: false, - quickstartAllowFrom: true, - forceAccountBinding: true, - preferSessionLookupForAnnounceTarget: true, - }, - setupWizard: whatsappSetupWizardProxy, + ...createWhatsAppPluginBase({ + setupWizard: whatsappSetupWizardProxy, + setup: whatsappSetupAdapter, + isConfigured: async (account) => + await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir), + }), agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()], pairing: { idLabel: "whatsappSenderId", }, - capabilities: { - chatTypes: ["direct", "group"], - polls: true, - reactions: true, - media: true, - }, - reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, - gatewayMethods: ["web.login.start", "web.login.wait"], - configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), - config: { - listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - const existing = accounts[accountKey] ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: { - ...accounts, - [accountKey]: { - ...existing, - enabled, - }, - }, - }, - }, - }; - }, - deleteAccount: ({ cfg, accountId }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - delete accounts[accountKey]; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: Object.keys(accounts).length ? accounts : undefined, - }, - }, - }; - }, - isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, - disabledReason: () => "disabled", - isConfigured: async (account) => - await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir), - unconfiguredReason: () => "not linked", - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.authDir), - linked: Boolean(account.authDir), - dmPolicy: account.dmPolicy, - allowFrom: account.allowFrom, - }), - resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), - }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => { @@ -157,53 +71,6 @@ export const whatsappPlugin: ChannelPlugin = { }), }), }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "whatsapp", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.dmPolicy, - allowFrom: account.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw), - }); - }, - collectWarnings: ({ account, cfg }) => { - const groupAllowlistConfigured = - Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.whatsapp !== undefined, - configuredGroupPolicy: account.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: groupAllowlistConfigured, - restrictSenders: { - surface: "WhatsApp groups", - openScope: "any member in allowed groups", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "WhatsApp groups", - routeAllowlistPath: "channels.whatsapp.groups", - routeScope: "group", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - }), - }); - }, - }, - setup: whatsappSetupAdapter, - groups: { - resolveRequireMention: resolveWhatsAppGroupRequireMention, - resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, - }, mentions: { stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx), }, @@ -256,7 +123,7 @@ export const whatsappPlugin: ChannelPlugin = { supportsAction: ({ action }) => action === "react", handleAction: async ({ action, params, cfg, accountId }) => { if (action !== "react") { - throw new Error(`Action ${action} is not supported for provider ${meta.id}.`); + throw new Error(`Action ${action} is not supported for provider ${WHATSAPP_CHANNEL}.`); } const messageId = readStringParam(params, "messageId", { required: true, @@ -297,7 +164,7 @@ export const whatsappPlugin: ChannelPlugin = { }, auth: { login: async ({ cfg, accountId, runtime, verbose }) => { - const resolvedAccountId = accountId?.trim() || resolveDefaultWhatsAppAccountId(cfg); + const resolvedAccountId = accountId?.trim() || whatsappPlugin.config.defaultAccountId(cfg); await ( await loadWhatsAppChannelRuntime() ).loginWeb(Boolean(verbose), undefined, runtime, resolvedAccountId); diff --git a/extensions/whatsapp/src/plugin-shared.ts b/extensions/whatsapp/src/plugin-shared.ts deleted file mode 100644 index fee78e620a4..00000000000 --- a/extensions/whatsapp/src/plugin-shared.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ChannelPlugin } from "openclaw/plugin-sdk/whatsapp"; -import { type ResolvedWhatsAppAccount } from "./accounts.js"; - -export async function loadWhatsAppChannelRuntime() { - return await import("./channel.runtime.js"); -} - -export const whatsappSetupWizardProxy = { - channel: "whatsapp", - status: { - configuredLabel: "linked", - unconfiguredLabel: "not linked", - configuredHint: "linked", - unconfiguredHint: "not linked", - configuredScore: 5, - unconfiguredScore: 4, - resolveConfigured: async ({ cfg }) => - await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.status.resolveConfigured({ - cfg, - }), - resolveStatusLines: async ({ cfg, configured }) => - (await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.status.resolveStatusLines?.({ - cfg, - configured, - })) ?? [], - }, - resolveShouldPromptAccountIds: (params) => - (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, - credentials: [], - finalize: async (params) => - await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.finalize!(params), - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - enabled: false, - }, - }, - }), - onAccountRecorded: (accountId, options) => { - options?.onWhatsAppAccountId?.(accountId); - }, -} satisfies NonNullable["setupWizard"]>; diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 3a8f7412e7e..43df9bd7e6a 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -24,6 +24,14 @@ import { export const WHATSAPP_CHANNEL = "whatsapp" as const; +export async function loadWhatsAppChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const whatsappSetupWizardProxy = createWhatsAppSetupWizardProxy(async () => ({ + whatsappSetupWizard: (await loadWhatsAppChannelRuntime()).whatsappSetupWizard, +})); + export function createWhatsAppSetupWizardProxy( loadWizard: () => Promise<{ whatsappSetupWizard: NonNullable["setupWizard"]>; From e820c255bc79308dd1098246bdbac54a9b16bfe7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:52:25 +0000 Subject: [PATCH 120/128] refactor(telegram): share plugin base config --- extensions/telegram/src/channel.setup.ts | 68 +++--------------------- extensions/telegram/src/channel.ts | 60 ++------------------- 2 files changed, 11 insertions(+), 117 deletions(-) diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index 09b41cddb0f..4879ef96c09 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -1,69 +1,13 @@ -import { - buildChannelConfigSchema, - getChatChannelMeta, - TelegramConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/telegram"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/telegram"; import { type ResolvedTelegramAccount } from "./accounts.js"; import type { TelegramProbe } from "./probe.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; -import { - findTelegramTokenOwnerAccountId, - formatDuplicateTelegramTokenReason, - telegramConfigAccessors, - telegramConfigBase, -} from "./shared.js"; +import { createTelegramPluginBase } from "./shared.js"; export const telegramSetupPlugin: ChannelPlugin = { - id: "telegram", - meta: { - ...getChatChannelMeta("telegram"), - quickstartAllowFrom: true, - }, - setupWizard: telegramSetupWizard, - capabilities: { - chatTypes: ["direct", "group", "channel", "thread"], - reactions: true, - threads: true, - media: true, - polls: true, - nativeCommands: true, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.telegram"] }, - configSchema: buildChannelConfigSchema(TelegramConfigSchema), - config: { - ...telegramConfigBase, - isConfigured: (account, cfg) => { - if (!account.token?.trim()) { - return false; - } - return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - }, - unconfiguredReason: (account, cfg) => { - if (!account.token?.trim()) { - return "not configured"; - } - const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - if (!ownerAccountId) { - return "not configured"; - } - return formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }); - }, - describeAccount: (account, cfg) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: - Boolean(account.token?.trim()) && - !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), - tokenSource: account.tokenSource, - }), - ...telegramConfigAccessors, - }, - setup: telegramSetupAdapter, + ...createTelegramPluginBase({ + setupWizard: telegramSetupWizard, + setup: telegramSetupAdapter, + }), }; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 0054aaba6de..ebd8ddc2c24 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -15,11 +15,9 @@ import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-run import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra-runtime"; import { parseTelegramTopicConversation } from "openclaw/plugin-sdk/telegram"; import { - buildChannelConfigSchema, buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, - getChatChannelMeta, listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, PAIRING_APPROVED_MESSAGE, @@ -27,7 +25,6 @@ import { resolveConfiguredFromCredentialStatuses, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, - TelegramConfigSchema, type ChannelPlugin, type ChannelMessageActionAdapter, type OpenClawConfig, @@ -54,10 +51,10 @@ import { sendTypingTelegram } from "./send.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; import { + createTelegramPluginBase, findTelegramTokenOwnerAccountId, formatDuplicateTelegramTokenReason, telegramConfigAccessors, - telegramConfigBase, } from "./shared.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; @@ -66,8 +63,6 @@ type TelegramSendFn = ReturnType< typeof getTelegramRuntime >["channel"]["telegram"]["sendMessageTelegram"]; -const meta = getChatChannelMeta("telegram"); - type TelegramSendOptions = NonNullable[2]>; function buildTelegramSendOptions(params: { @@ -324,12 +319,10 @@ function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) { } export const telegramPlugin: ChannelPlugin = { - id: "telegram", - meta: { - ...meta, - quickstartAllowFrom: true, - }, - setupWizard: telegramSetupWizard, + ...createTelegramPluginBase({ + setupWizard: telegramSetupWizard, + setup: telegramSetupAdapter, + }), pairing: { idLabel: "telegramUserId", normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""), @@ -347,49 +340,6 @@ export const telegramPlugin: ChannelPlugin { - if (!account.token?.trim()) { - return false; - } - return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - }, - unconfiguredReason: (account, cfg) => { - if (!account.token?.trim()) { - return "not configured"; - } - const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - if (!ownerAccountId) { - return "not configured"; - } - return formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }); - }, - describeAccount: (account, cfg) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: - Boolean(account.token?.trim()) && - !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), - tokenSource: account.tokenSource, - }), - ...telegramConfigAccessors, - }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => From 21bc5a90eced9596da7e7fee317b40c0318a5f51 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:55:00 +0000 Subject: [PATCH 121/128] fix(slack): restore setup wizard base export --- extensions/slack/src/setup-core.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 0b4c63c8b70..3da152d2f37 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,5 +1,3 @@ -import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; -import { createAllowlistSetupWizardProxy } from "../../../src/channels/plugins/setup-wizard-proxy.js"; import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, @@ -22,6 +20,8 @@ import { type ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "../../../src/terminal/links.js"; +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; +import { createAllowlistSetupWizardProxy } from "../../../src/channels/plugins/setup-wizard-proxy.js"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; import { @@ -112,7 +112,7 @@ export const slackSetupAdapter: ChannelSetupAdapter = { }, }; -export function createSlackSetupWizardProxy( +export function createSlackSetupWizardBase( loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, ) { const slackDmPolicy: ChannelSetupDmPolicy = { From f3da2920974634b2cf2c391ec93ef66c99609a13 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:55:07 +0000 Subject: [PATCH 122/128] refactor(slack): share plugin base config --- extensions/slack/src/channel.setup.ts | 57 +++----------------------- extensions/slack/src/channel.ts | 59 +++++---------------------- 2 files changed, 17 insertions(+), 99 deletions(-) diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index 2fbaac93ab6..854e1782315 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -1,57 +1,12 @@ -import { - buildChannelConfigSchema, - getChatChannelMeta, - SlackConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/slack"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/slack"; import { type ResolvedSlackAccount } from "./accounts.js"; -import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { slackSetupAdapter } from "./setup-core.js"; import { slackSetupWizard } from "./setup-surface.js"; -import { isSlackPluginAccountConfigured, slackConfigAccessors, slackConfigBase } from "./shared.js"; +import { createSlackPluginBase } from "./shared.js"; export const slackSetupPlugin: ChannelPlugin = { - id: "slack", - meta: { - ...getChatChannelMeta("slack"), - preferSessionLookupForAnnounceTarget: true, - }, - setupWizard: slackSetupWizard, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - agentPrompt: { - messageToolHints: ({ cfg, accountId }) => - isSlackInteractiveRepliesEnabled({ cfg, accountId }) - ? [ - "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", - "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", - ] - : [ - "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", - ], - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.slack"] }, - configSchema: buildChannelConfigSchema(SlackConfigSchema), - config: { - ...slackConfigBase, - isConfigured: (account) => isSlackPluginAccountConfigured(account), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: isSlackPluginAccountConfigured(account), - botTokenSource: account.botTokenSource, - appTokenSource: account.appTokenSource, - }), - ...slackConfigAccessors, - }, - setup: slackSetupAdapter, + ...createSlackPluginBase({ + setupWizard: slackSetupWizard, + setup: slackSetupAdapter, + }), }; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index cdddeadebfe..66e640e1bcf 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -15,9 +15,7 @@ import { } from "openclaw/plugin-sdk/core"; import { buildComputedAccountStatusSnapshot, - buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, - getChatChannelMeta, listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, looksLikeSlackTargetId, @@ -27,7 +25,6 @@ import { resolveConfiguredFromRequiredCredentialStatuses, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, - SlackConfigSchema, type ChannelPlugin, type OpenClawConfig, } from "openclaw/plugin-sdk/slack"; @@ -50,11 +47,15 @@ import { getSlackRuntime } from "./runtime.js"; import { fetchSlackScopes } from "./scopes.js"; import { slackSetupAdapter } from "./setup-core.js"; import { slackSetupWizard } from "./setup-surface.js"; -import { isSlackPluginAccountConfigured, slackConfigAccessors, slackConfigBase } from "./shared.js"; +import { + createSlackPluginBase, + isSlackPluginAccountConfigured, + slackConfigAccessors, + SLACK_CHANNEL, +} from "./shared.js"; import { parseSlackTarget } from "./targets.js"; import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; -const meta = getChatChannelMeta("slack"); const SLACK_CHANNEL_TYPE_CACHE = new Map(); // Select the appropriate Slack token for read/write operations. @@ -329,12 +330,10 @@ async function resolveSlackAllowlistNames(params: { } export const slackPlugin: ChannelPlugin = { - id: "slack", - meta: { - ...meta, - preferSessionLookupForAnnounceTarget: true, - }, - setupWizard: slackSetupWizard, + ...createSlackPluginBase({ + setupWizard: slackSetupWizard, + setup: slackSetupAdapter, + }), pairing: { idLabel: "slackUserId", normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), @@ -363,42 +362,6 @@ export const slackPlugin: ChannelPlugin = { } }, }, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - agentPrompt: { - messageToolHints: ({ cfg, accountId }) => - isSlackInteractiveRepliesEnabled({ cfg, accountId }) - ? [ - "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", - "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", - ] - : [ - "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", - ], - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.slack"] }, - configSchema: buildChannelConfigSchema(SlackConfigSchema), - config: { - ...slackConfigBase, - isConfigured: (account) => isSlackPluginAccountConfigured(account), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: isSlackPluginAccountConfigured(account), - botTokenSource: account.botTokenSource, - appTokenSource: account.appTokenSource, - }), - ...slackConfigAccessors, - }, allowlist: { supportsScope: ({ scope }) => scope === "dm", readConfig: ({ cfg, accountId }) => @@ -561,7 +524,7 @@ export const slackPlugin: ChannelPlugin = { extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => await handleSlackMessageAction({ - providerId: meta.id, + providerId: SLACK_CHANNEL, ctx, includeReadThreadId: true, invoke: async (action, cfg, toolContext) => From 423f1e994e0c68fbe7e7e4e84175cc1b44dbe260 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:56:38 +0000 Subject: [PATCH 123/128] refactor(signal): share plugin base config --- extensions/signal/src/channel.setup.ts | 69 +++----------------------- extensions/signal/src/channel.ts | 62 +++-------------------- 2 files changed, 13 insertions(+), 118 deletions(-) diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index b81d10cc99d..f3f8ea9bef2 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -2,70 +2,16 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { - buildChannelConfigSchema, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - getChatChannelMeta, - normalizeE164, - setAccountEnabledInConfigSection, - SignalConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/signal"; -import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, - type ResolvedSignalAccount, -} from "./accounts.js"; -import { signalConfigAccessors, signalSetupWizard } from "./plugin-shared.js"; +import { DEFAULT_ACCOUNT_ID, normalizeE164, type ChannelPlugin } from "openclaw/plugin-sdk/signal"; +import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; import { signalSetupAdapter } from "./setup-core.js"; +import { createSignalPluginBase, signalSetupWizard } from "./shared.js"; export const signalSetupPlugin: ChannelPlugin = { - id: "signal", - meta: { - ...getChatChannelMeta("signal"), - }, - setupWizard: signalSetupWizard, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: true, - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.signal"] }, - configSchema: buildChannelConfigSchema(SignalConfigSchema), - config: { - listAccountIds: (cfg) => listSignalAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "signal", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "signal", - accountId, - clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - baseUrl: account.baseUrl, - }), - ...signalConfigAccessors, - }, + ...createSignalPluginBase({ + setupWizard: signalSetupWizard, + setup: signalSetupAdapter, + }), security: { resolveDmPolicy: ({ cfg, accountId, account }) => buildAccountScopedDmSecurityPolicy({ @@ -90,5 +36,4 @@ export const signalSetupPlugin: ChannelPlugin = { mentionGated: false, }), }, - setup: signalSetupAdapter, }; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index aba60d3e29a..bd0085e9dfd 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -10,28 +10,18 @@ import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, - buildChannelConfigSchema, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - getChatChannelMeta, looksLikeSignalTargetId, normalizeE164, normalizeSignalMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, - setAccountEnabledInConfigSection, - SignalConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, } from "openclaw/plugin-sdk/signal"; -import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, - type ResolvedSignalAccount, -} from "./accounts.js"; +import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; import { markdownToSignalTextChunks } from "./format.js"; import { looksLikeUuid, @@ -39,10 +29,10 @@ import { resolveSignalRecipient, resolveSignalSender, } from "./identity.js"; -import { signalConfigAccessors, signalSetupWizard } from "./plugin-shared.js"; import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; import { signalSetupAdapter } from "./setup-core.js"; +import { createSignalPluginBase, signalConfigAccessors, signalSetupWizard } from "./shared.js"; const signalMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [], @@ -292,11 +282,10 @@ async function sendFormattedSignalMedia(ctx: { } export const signalPlugin: ChannelPlugin = { - id: "signal", - meta: { - ...getChatChannelMeta("signal"), - }, - setupWizard: signalSetupWizard, + ...createSignalPluginBase({ + setupWizard: signalSetupWizard, + setup: signalSetupAdapter, + }), pairing: { idLabel: "signalNumber", normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""), @@ -304,46 +293,7 @@ export const signalPlugin: ChannelPlugin = { await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE); }, }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: true, - }, actions: signalMessageActions, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.signal"] }, - configSchema: buildChannelConfigSchema(SignalConfigSchema), - config: { - listAccountIds: (cfg) => listSignalAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "signal", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "signal", - accountId, - clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - baseUrl: account.baseUrl, - }), - ...signalConfigAccessors, - }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => { From e36f16e750ef08d652dd692d137636672e8f80c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:57:46 +0000 Subject: [PATCH 124/128] refactor(imessage): share plugin base config --- extensions/imessage/src/channel.setup.ts | 70 +++--------------------- extensions/imessage/src/channel.ts | 66 ++-------------------- 2 files changed, 13 insertions(+), 123 deletions(-) diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts index 0590eba9356..df0750a4284 100644 --- a/extensions/imessage/src/channel.setup.ts +++ b/extensions/imessage/src/channel.setup.ts @@ -2,71 +2,16 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { - buildChannelConfigSchema, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - formatTrimmedAllowFromEntries, - getChatChannelMeta, - IMessageConfigSchema, - resolveIMessageConfigAllowFrom, - resolveIMessageConfigDefaultTo, - setAccountEnabledInConfigSection, - type ChannelPlugin, -} from "openclaw/plugin-sdk/imessage"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, - type ResolvedIMessageAccount, -} from "./accounts.js"; -import { imessageSetupWizard } from "./plugin-shared.js"; +import { DEFAULT_ACCOUNT_ID, type ChannelPlugin } from "openclaw/plugin-sdk/imessage"; +import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { imessageSetupAdapter } from "./setup-core.js"; +import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; export const imessageSetupPlugin: ChannelPlugin = { - id: "imessage", - meta: { - ...getChatChannelMeta("imessage"), - aliases: ["imsg"], - showConfigured: false, - }, - setupWizard: imessageSetupWizard, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - }, - reload: { configPrefixes: ["channels.imessage"] }, - configSchema: buildChannelConfigSchema(IMessageConfigSchema), - config: { - listAccountIds: (cfg) => listIMessageAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - }), - resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), - }, + ...createIMessagePluginBase({ + setupWizard: imessageSetupWizard, + setup: imessageSetupAdapter, + }), security: { resolveDmPolicy: ({ cfg, accountId, account }) => buildAccountScopedDmSecurityPolicy({ @@ -90,5 +35,4 @@ export const imessageSetupPlugin: ChannelPlugin = { mentionGated: false, }), }, - setup: imessageSetupAdapter, }; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 5e3d48817a0..bf7e6585d6c 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -6,38 +6,23 @@ import { import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core"; import { - buildChannelConfigSchema, collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - formatTrimmedAllowFromEntries, - getChatChannelMeta, - IMessageConfigSchema, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, - resolveIMessageConfigAllowFrom, - resolveIMessageConfigDefaultTo, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, - setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk/imessage"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, - type ResolvedIMessageAccount, -} from "./accounts.js"; -import { imessageSetupWizard } from "./plugin-shared.js"; +import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { getIMessageRuntime } from "./runtime.js"; import { imessageSetupAdapter } from "./setup-core.js"; +import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; -const meta = getChatChannelMeta("imessage"); - type IMessageSendFn = ReturnType< typeof getIMessageRuntime >["channel"]["imessage"]["sendMessageIMessage"]; @@ -150,55 +135,16 @@ function resolveIMessageOutboundSessionRoute(params: { } export const imessagePlugin: ChannelPlugin = { - id: "imessage", - meta: { - ...meta, - aliases: ["imsg"], - showConfigured: false, - }, - setupWizard: imessageSetupWizard, + ...createIMessagePluginBase({ + setupWizard: imessageSetupWizard, + setup: imessageSetupAdapter, + }), pairing: { idLabel: "imessageSenderId", notifyApproval: async ({ id }) => { await getIMessageRuntime().channel.imessage.sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE); }, }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - }, - reload: { configPrefixes: ["channels.imessage"] }, - configSchema: buildChannelConfigSchema(IMessageConfigSchema), - config: { - listAccountIds: (cfg) => listIMessageAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - }), - resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), - }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => { From 626e3015027c53de7e36c9defc3ab5699a45364b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 04:58:55 +0000 Subject: [PATCH 125/128] refactor(channels): remove dead shared plugin duplicates --- extensions/imessage/src/plugin-shared.ts | 11 ---------- extensions/signal/src/plugin-shared.ts | 26 ------------------------ 2 files changed, 37 deletions(-) delete mode 100644 extensions/imessage/src/plugin-shared.ts delete mode 100644 extensions/signal/src/plugin-shared.ts diff --git a/extensions/imessage/src/plugin-shared.ts b/extensions/imessage/src/plugin-shared.ts deleted file mode 100644 index 415a152f56a..00000000000 --- a/extensions/imessage/src/plugin-shared.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ChannelPlugin } from "openclaw/plugin-sdk/imessage"; -import { type ResolvedIMessageAccount } from "./accounts.js"; -import { createIMessageSetupWizardProxy } from "./setup-core.js"; - -async function loadIMessageChannelRuntime() { - return await import("./channel.runtime.js"); -} - -export const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ - imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, -})) satisfies NonNullable["setupWizard"]>; diff --git a/extensions/signal/src/plugin-shared.ts b/extensions/signal/src/plugin-shared.ts deleted file mode 100644 index 8755caf240f..00000000000 --- a/extensions/signal/src/plugin-shared.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { createScopedAccountConfigAccessors } from "../../../src/plugin-sdk-internal/channel-config.js"; -import { normalizeE164 } from "../../../src/utils.js"; -import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; -import { createSignalSetupWizardProxy } from "./setup-core.js"; - -async function loadSignalChannelRuntime() { - return await import("./channel.runtime.js"); -} - -export const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ - signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, -})); - -export const signalConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => - resolveSignalAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) - .filter(Boolean), - resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, -}); From a1a8b74e9a57ea80b1a2b0a2ae225a7a280c142c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 05:00:59 +0000 Subject: [PATCH 126/128] refactor(nextcloud-talk): share dm policy prompt --- extensions/nextcloud-talk/src/setup-core.ts | 2 +- .../nextcloud-talk/src/setup-surface.ts | 93 +------------------ 2 files changed, 4 insertions(+), 91 deletions(-) diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 0b74753dcb6..4e976605b85 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -174,7 +174,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: { }); } -const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { +export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { label: "Nextcloud Talk", channel, policyKey: "channels.nextcloud-talk.dmPolicy", diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index ecb7b29084d..776a9a4fe3e 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -2,23 +2,13 @@ import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; -import { - mergeAllowFromEntries, - resolveSetupAccountId, - setSetupChannelEnabled, - setTopLevelChannelDmPolicyWithAllowFrom, -} from "openclaw/plugin-sdk/setup"; -import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; +import { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "openclaw/plugin-sdk/setup"; -import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; -import { - listNextcloudTalkAccountIds, - resolveDefaultNextcloudTalkAccountId, - resolveNextcloudTalkAccount, -} from "./accounts.js"; +import { listNextcloudTalkAccountIds, resolveNextcloudTalkAccount } from "./accounts.js"; import { clearNextcloudTalkAccountFields, + nextcloudTalkDmPolicy, nextcloudTalkSetupAdapter, normalizeNextcloudTalkBaseUrl, setNextcloudTalkAccountConfig, @@ -29,83 +19,6 @@ import type { CoreConfig, DmPolicy } from "./types.js"; const channel = "nextcloud-talk" as const; const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials"; -function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as CoreConfig; -} - -async function promptNextcloudTalkAllowFrom(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId: string; -}): Promise { - const resolved = resolveNextcloudTalkAccount({ cfg: params.cfg, accountId: params.accountId }); - const existingAllowFrom = resolved.config.allowFrom ?? []; - await params.prompter.note( - [ - "1) Check the Nextcloud admin panel for user IDs", - "2) Or look at the webhook payload logs when someone messages", - "3) User IDs are typically lowercase usernames in Nextcloud", - `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, - ].join("\n"), - "Nextcloud Talk user id", - ); - - let resolvedIds: string[] = []; - while (resolvedIds.length === 0) { - const entry = await params.prompter.text({ - message: "Nextcloud Talk allowFrom (user id)", - placeholder: "username", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - resolvedIds = String(entry) - .split(/[\n,;]+/g) - .map((value) => value.trim().toLowerCase()) - .filter(Boolean); - if (resolvedIds.length === 0) { - await params.prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk"); - } - } - - return setNextcloudTalkAccountConfig(params.cfg, params.accountId, { - dmPolicy: "allowlist", - allowFrom: mergeAllowFromEntries( - existingAllowFrom.map((value) => String(value).trim().toLowerCase()), - resolvedIds, - ), - }); -} - -async function promptNextcloudTalkAllowFromForAccount(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = resolveSetupAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), - }); - return await promptNextcloudTalkAllowFrom({ - cfg: params.cfg as CoreConfig, - prompter: params.prompter, - accountId, - }); -} - -const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { - label: "Nextcloud Talk", - channel, - policyKey: "channels.nextcloud-talk.dmPolicy", - allowFromKey: "channels.nextcloud-talk.allowFrom", - getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), - promptAllowFrom: promptNextcloudTalkAllowFromForAccount, -}; - export const nextcloudTalkSetupWizard: ChannelSetupWizard = { channel, stepOrder: "text-first", From ec89357547ec106238af8594ef1fe21c29ef6d44 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 05:03:10 +0000 Subject: [PATCH 127/128] refactor(signal): share setup wizard helpers --- extensions/signal/src/setup-core.ts | 141 ++++++++++++++----------- extensions/signal/src/setup-surface.ts | 112 +++----------------- 2 files changed, 93 insertions(+), 160 deletions(-) diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 9b487ead841..55d41ce458d 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -16,6 +16,7 @@ import type { ChannelSetupAdapter, ChannelSetupDmPolicy, ChannelSetupWizard, + ChannelSetupWizardTextInput, } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { @@ -87,7 +88,7 @@ function buildSignalSetupPatch(input: { }; } -async function promptSignalAllowFrom(params: { +export async function promptSignalAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string; @@ -115,6 +116,77 @@ async function promptSignalAllowFrom(params: { }); } +export const signalDmPolicy: ChannelSetupDmPolicy = { + label: "Signal", + channel, + policyKey: "channels.signal.dmPolicy", + allowFromKey: "channels.signal.allowFrom", + getCurrent: (cfg: OpenClawConfig) => cfg.channels?.signal?.dmPolicy ?? "pairing", + setPolicy: (cfg: OpenClawConfig, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptSignalAllowFrom, +}; + +function resolveSignalCliPath(params: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: Record; +}) { + return ( + (typeof params.credentialValues.cliPath === "string" + ? params.credentialValues.cliPath + : undefined) ?? + resolveSignalAccount({ cfg: params.cfg, accountId: params.accountId }).config.cliPath ?? + "signal-cli" + ); +} + +export function createSignalCliPathTextInput( + shouldPrompt: NonNullable, +): ChannelSetupWizardTextInput { + return { + inputKey: "cliPath", + message: "signal-cli path", + currentValue: ({ cfg, accountId, credentialValues }) => + resolveSignalCliPath({ cfg, accountId, credentialValues }), + initialValue: ({ cfg, accountId, credentialValues }) => + resolveSignalCliPath({ cfg, accountId, credentialValues }), + shouldPrompt, + confirmCurrentValue: false, + applyCurrentValue: true, + helpTitle: "Signal", + helpLines: [ + "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", + ], + }; +} + +export const signalNumberTextInput: ChannelSetupWizardTextInput = { + inputKey: "signalNumber", + message: "Signal bot number (E.164)", + currentValue: ({ cfg, accountId }) => + normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ?? + undefined, + keepPrompt: (value) => `Signal account set (${value}). Keep it?`, + validate: ({ value }) => + normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR, + normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value, +}; + +export const signalCompletionNote = { + title: "Signal next steps", + lines: [ + 'Link device with: signal-cli link -n "OpenClaw"', + "Scan QR in Signal -> Linked Devices", + `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, + `Docs: ${formatDocsLink("/signal", "signal")}`, + ], +}; + export const signalSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => @@ -187,21 +259,6 @@ export const signalSetupAdapter: ChannelSetupAdapter = { export function createSignalSetupWizardProxy( loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, ) { - const signalDmPolicy: ChannelSetupDmPolicy = { - label: "Signal", - channel, - policyKey: "channels.signal.dmPolicy", - allowFromKey: "channels.signal.allowFrom", - getCurrent: (cfg: OpenClawConfig) => cfg.channels?.signal?.dmPolicy ?? "pairing", - setPolicy: (cfg: OpenClawConfig, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptSignalAllowFrom, - }; - return { channel, status: { @@ -225,51 +282,15 @@ export function createSignalSetupWizardProxy( prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params), credentials: [], textInputs: [ - { - inputKey: "cliPath", - message: "signal-cli path", - currentValue: ({ cfg, accountId, credentialValues }) => - (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? - resolveSignalAccount({ cfg, accountId }).config.cliPath ?? - "signal-cli", - initialValue: ({ cfg, accountId, credentialValues }) => - (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? - resolveSignalAccount({ cfg, accountId }).config.cliPath ?? - "signal-cli", - shouldPrompt: async (params) => { - const input = (await loadWizard()).signalSetupWizard.textInputs?.find( - (entry) => entry.inputKey === "cliPath", - ); - return (await input?.shouldPrompt?.(params)) ?? false; - }, - confirmCurrentValue: false, - applyCurrentValue: true, - helpTitle: "Signal", - helpLines: [ - "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", - ], - }, - { - inputKey: "signalNumber", - message: "Signal bot number (E.164)", - currentValue: ({ cfg, accountId }) => - normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ?? - undefined, - keepPrompt: (value) => `Signal account set (${value}). Keep it?`, - validate: ({ value }) => - normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR, - normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value, - }, + createSignalCliPathTextInput(async (params) => { + const input = (await loadWizard()).signalSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }), + signalNumberTextInput, ], - completionNote: { - title: "Signal next steps", - lines: [ - 'Link device with: signal-cli link -n "OpenClaw"', - "Scan QR in Signal -> Linked Devices", - `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, - `Docs: ${formatDocsLink("/signal", "signal")}`, - ], - }, + completionNote: signalCompletionNote, dmPolicy: signalDmPolicy, disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 3e2f39cde2d..695e2c5cc8b 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,73 +1,19 @@ -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 { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js"; import { - DEFAULT_ACCOUNT_ID, - type OpenClawConfig, - promptParsedAllowFromForScopedChannel, - setChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, - type WizardPrompter, -} from "openclaw/plugin-sdk/setup"; -import type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, -} from "./accounts.js"; -import { + createSignalCliPathTextInput, normalizeSignalAccountInput, parseSignalAllowFromEntries, + signalCompletionNote, + signalDmPolicy, + signalNumberTextInput, signalSetupAdapter, } from "./setup-core.js"; const channel = "signal" as const; -const INVALID_SIGNAL_ACCOUNT_ERROR = - "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; - -async function promptSignalAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - return promptParsedAllowFromForScopedChannel({ - cfg: params.cfg, - channel, - accountId: params.accountId, - defaultAccountId: resolveDefaultSignalAccountId(params.cfg), - prompter: params.prompter, - noteTitle: "Signal allowlist", - noteLines: [ - "Allowlist Signal DMs by sender id.", - "Examples:", - "- +15555550123", - "- uuid:123e4567-e89b-12d3-a456-426614174000", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/signal", "signal")}`, - ], - message: "Signal allowFrom (E.164 or uuid)", - placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000", - parseEntries: parseSignalAllowFromEntries, - getExistingAllowFrom: ({ cfg, accountId }) => - resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? [], - }); -} - -const signalDmPolicy: ChannelSetupDmPolicy = { - label: "Signal", - channel, - policyKey: "channels.signal.dmPolicy", - allowFromKey: "channels.signal.allowFrom", - getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptSignalAllowFrom, -}; export const signalSetupWizard: ChannelSetupWizard = { channel, @@ -136,46 +82,12 @@ export const signalSetupWizard: ChannelSetupWizard = { }, credentials: [], textInputs: [ - { - inputKey: "cliPath", - message: "signal-cli path", - currentValue: ({ cfg, accountId, credentialValues }) => - (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? - resolveSignalAccount({ cfg, accountId }).config.cliPath ?? - "signal-cli", - initialValue: ({ cfg, accountId, credentialValues }) => - (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? - resolveSignalAccount({ cfg, accountId }).config.cliPath ?? - "signal-cli", - shouldPrompt: async ({ currentValue }) => !(await detectBinary(currentValue ?? "signal-cli")), - confirmCurrentValue: false, - applyCurrentValue: true, - helpTitle: "Signal", - helpLines: [ - "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", - ], - }, - { - inputKey: "signalNumber", - message: "Signal bot number (E.164)", - currentValue: ({ cfg, accountId }) => - normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ?? - undefined, - keepPrompt: (value) => `Signal account set (${value}). Keep it?`, - validate: ({ value }) => - normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR, - normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value, - }, + createSignalCliPathTextInput(async ({ currentValue }) => { + return !(await detectBinary(currentValue ?? "signal-cli")); + }), + signalNumberTextInput, ], - completionNote: { - title: "Signal next steps", - lines: [ - 'Link device with: signal-cli link -n "OpenClaw"', - "Scan QR in Signal -> Linked Devices", - `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, - `Docs: ${formatDocsLink("/signal", "signal")}`, - ], - }, + completionNote: signalCompletionNote, dmPolicy: signalDmPolicy, disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; From 6cbff9e7d39cfc29c8dd0f61f06d505ef35d6f98 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 05:04:19 +0000 Subject: [PATCH 128/128] refactor(imessage): share setup wizard helpers --- extensions/imessage/src/setup-core.ts | 99 +++++++++++++----------- extensions/imessage/src/setup-surface.ts | 97 ++++------------------- 2 files changed, 68 insertions(+), 128 deletions(-) diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 2560c1cb919..bc99f521510 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -14,6 +14,7 @@ import type { ChannelSetupAdapter, ChannelSetupDmPolicy, ChannelSetupWizard, + ChannelSetupWizardTextInput, } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { @@ -68,7 +69,7 @@ function buildIMessageSetupPatch(input: { }; } -async function promptIMessageAllowFrom(params: { +export async function promptIMessageAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string; @@ -98,6 +99,52 @@ async function promptIMessageAllowFrom(params: { }); } +export const imessageDmPolicy: ChannelSetupDmPolicy = { + label: "iMessage", + channel, + policyKey: "channels.imessage.dmPolicy", + allowFromKey: "channels.imessage.allowFrom", + getCurrent: (cfg: OpenClawConfig) => cfg.channels?.imessage?.dmPolicy ?? "pairing", + setPolicy: (cfg: OpenClawConfig, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptIMessageAllowFrom, +}; + +function resolveIMessageCliPath(params: { cfg: OpenClawConfig; accountId: string }) { + return resolveIMessageAccount(params).config.cliPath ?? "imsg"; +} + +export function createIMessageCliPathTextInput( + shouldPrompt: NonNullable, +): ChannelSetupWizardTextInput { + return { + inputKey: "cliPath", + message: "imsg CLI path", + initialValue: ({ cfg, accountId }) => resolveIMessageCliPath({ cfg, accountId }), + currentValue: ({ cfg, accountId }) => resolveIMessageCliPath({ cfg, accountId }), + shouldPrompt, + confirmCurrentValue: false, + applyCurrentValue: true, + helpTitle: "iMessage", + helpLines: ["imsg CLI path required to enable iMessage."], + }; +} + +export const imessageCompletionNote = { + title: "iMessage next steps", + lines: [ + "This is still a work in progress.", + "Ensure OpenClaw has Full Disk Access to Messages DB.", + "Grant Automation permission for Messages when prompted.", + "List chats with: imsg chats --limit 20", + `Docs: ${formatDocsLink("/imessage", "imessage")}`, + ], +}; + export const imessageSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => @@ -158,21 +205,6 @@ export const imessageSetupAdapter: ChannelSetupAdapter = { export function createIMessageSetupWizardProxy( loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>, ) { - const imessageDmPolicy: ChannelSetupDmPolicy = { - label: "iMessage", - channel, - policyKey: "channels.imessage.dmPolicy", - allowFromKey: "channels.imessage.allowFrom", - getCurrent: (cfg: OpenClawConfig) => cfg.channels?.imessage?.dmPolicy ?? "pairing", - setPolicy: (cfg: OpenClawConfig, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptIMessageAllowFrom, - }; - return { channel, status: { @@ -202,35 +234,14 @@ export function createIMessageSetupWizardProxy( }, credentials: [], textInputs: [ - { - inputKey: "cliPath", - message: "imsg CLI path", - initialValue: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", - currentValue: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", - shouldPrompt: async (params) => { - const input = (await loadWizard()).imessageSetupWizard.textInputs?.find( - (entry) => entry.inputKey === "cliPath", - ); - return (await input?.shouldPrompt?.(params)) ?? false; - }, - confirmCurrentValue: false, - applyCurrentValue: true, - helpTitle: "iMessage", - helpLines: ["imsg CLI path required to enable iMessage."], - }, + createIMessageCliPathTextInput(async (params) => { + const input = (await loadWizard()).imessageSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }), ], - completionNote: { - title: "iMessage next steps", - lines: [ - "This is still a work in progress.", - "Ensure OpenClaw has Full Disk Access to Messages DB.", - "Grant Automation permission for Messages when prompted.", - "List chats with: imsg chats --limit 20", - `Docs: ${formatDocsLink("/imessage", "imessage")}`, - ], - }, + completionNote: imessageCompletionNote, dmPolicy: imessageDmPolicy, disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 2d66c4ab6b2..b2ccdb3a1d6 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,69 +1,17 @@ import { detectBinary } from "../../../src/commands/onboard-helpers.js"; +import { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { listIMessageAccountIds, resolveIMessageAccount } from "./accounts.js"; import { - DEFAULT_ACCOUNT_ID, - type OpenClawConfig, - parseSetupEntriesAllowingWildcard, - promptParsedAllowFromForScopedChannel, - setChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, - type WizardPrompter, -} from "openclaw/plugin-sdk/setup"; -import type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, -} from "./accounts.js"; -import { imessageSetupAdapter, parseIMessageAllowFromEntries } from "./setup-core.js"; + createIMessageCliPathTextInput, + imessageCompletionNote, + imessageDmPolicy, + imessageSetupAdapter, + parseIMessageAllowFromEntries, +} from "./setup-core.js"; const channel = "imessage" as const; -async function promptIMessageAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - return promptParsedAllowFromForScopedChannel({ - cfg: params.cfg, - channel, - accountId: params.accountId, - defaultAccountId: resolveDefaultIMessageAccountId(params.cfg), - prompter: params.prompter, - noteTitle: "iMessage allowlist", - noteLines: [ - "Allowlist iMessage DMs by handle or chat target.", - "Examples:", - "- +15555550123", - "- user@example.com", - "- chat_id:123", - "- chat_guid:... or chat_identifier:...", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/imessage", "imessage")}`, - ], - message: "iMessage allowFrom (handle or chat_id)", - placeholder: "+15555550123, user@example.com, chat_id:123", - parseEntries: parseIMessageAllowFromEntries, - getExistingAllowFrom: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? [], - }); -} - -const imessageDmPolicy: ChannelSetupDmPolicy = { - label: "iMessage", - channel, - policyKey: "channels.imessage.dmPolicy", - allowFromKey: "channels.imessage.allowFrom", - getCurrent: (cfg) => cfg.channels?.imessage?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptIMessageAllowFrom, -}; - export const imessageSetupWizard: ChannelSetupWizard = { channel, status: { @@ -103,30 +51,11 @@ export const imessageSetupWizard: ChannelSetupWizard = { }, credentials: [], textInputs: [ - { - inputKey: "cliPath", - message: "imsg CLI path", - initialValue: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", - currentValue: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", - shouldPrompt: async ({ currentValue }) => !(await detectBinary(currentValue ?? "imsg")), - confirmCurrentValue: false, - applyCurrentValue: true, - helpTitle: "iMessage", - helpLines: ["imsg CLI path required to enable iMessage."], - }, + createIMessageCliPathTextInput(async ({ currentValue }) => { + return !(await detectBinary(currentValue ?? "imsg")); + }), ], - completionNote: { - title: "iMessage next steps", - lines: [ - "This is still a work in progress.", - "Ensure OpenClaw has Full Disk Access to Messages DB.", - "Grant Automation permission for Messages when prompted.", - "List chats with: imsg chats --limit 20", - `Docs: ${formatDocsLink("/imessage", "imessage")}`, - ], - }, + completionNote: imessageCompletionNote, dmPolicy: imessageDmPolicy, disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), };