From 3e8bc9f16ac31e593dbc9655a02d72fe27d02826 Mon Sep 17 00:00:00 2001 From: MoerAI Date: Mon, 16 Mar 2026 12:11:58 +0900 Subject: [PATCH 001/133] fix(daemon): accept 'Last Result' schtasks key variant on Windows (#47726) Some Windows locales/versions emit 'Last Result' instead of 'Last Run Result' in schtasks output, causing gateway status to falsely report 'Runtime: unknown'. Fall back to the shorter key when the canonical key is absent. --- src/daemon/schtasks.test.ts | 14 ++++++++++++++ src/daemon/schtasks.ts | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/daemon/schtasks.test.ts b/src/daemon/schtasks.test.ts index 633df0fee7e..469ca584a7d 100644 --- a/src/daemon/schtasks.test.ts +++ b/src/daemon/schtasks.test.ts @@ -23,6 +23,20 @@ describe("schtasks runtime parsing", () => { lastRunResult: "0x0", }); }); + + it("parses 'Last Result' key variant (without 'Run') (#47726)", () => { + const output = [ + "TaskName: \\OpenClaw Gateway", + "Status: Running", + "Last Run Time: 2026/3/16 8:34:15", + "Last Result: 267009", + ].join("\r\n"); + expect(parseSchtasksQuery(output)).toEqual({ + status: "Running", + lastRunTime: "2026/3/16 8:34:15", + lastRunResult: "267009", + }); + }); }); describe("scheduled task runtime derivation", () => { diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 2216e93bfd9..816ade13390 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -178,7 +178,9 @@ export function parseSchtasksQuery(output: string): ScheduledTaskInfo { if (lastRunTime) { info.lastRunTime = lastRunTime; } - const lastRunResult = entries["last run result"]; + // Some Windows locales/versions emit "Last Result" instead of "Last Run Result". + // Accept both so gateway status is not falsely reported as "unknown" (#47726). + const lastRunResult = entries["last run result"] ?? entries["last result"]; if (lastRunResult) { info.lastRunResult = lastRunResult; } From abe7ea4373ae1ac75171ee63604119f29bf11732 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:20:08 +0000 Subject: [PATCH 002/133] fix: accept schtasks Last Result key on Windows (#47844) (thanks @MoerAI) --- CHANGELOG.md | 1 + src/cli/program.smoke.test.ts | 4 +-- src/cli/program.test-mocks.ts | 54 ++++++++++++++++++++--------------- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce45052af01..d8040d79aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras. - Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras. - Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc. +- Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI. ## 2026.3.13 diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index 259dd3b0360..9b8fc6dc454 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -4,10 +4,10 @@ import { ensureConfigReady, installBaseProgramMocks, installSmokeProgramMocks, - onboardCommand, runTui, runtime, setupCommand, + setupWizardCommand, } from "./program.test-mocks.js"; installBaseProgramMocks(); @@ -68,6 +68,6 @@ describe("cli program (smoke)", () => { await runProgram(["setup", "--remote-url", "ws://example"]); expect(setupCommand).not.toHaveBeenCalled(); - expect(onboardCommand).toHaveBeenCalledTimes(1); + expect(setupWizardCommand).toHaveBeenCalledTimes(1); }); }); diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index cf71122749f..8f82e71fca5 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -2,34 +2,39 @@ import { vi, type Mock } from "vitest"; type AnyMock = Mock<(...args: unknown[]) => unknown>; -const programMocks = vi.hoisted(() => ({ - messageCommand: vi.fn(), - statusCommand: vi.fn(), - configureCommand: vi.fn(), - configureCommandWithSections: vi.fn(), - setupCommand: vi.fn(), - onboardCommand: vi.fn(), - callGateway: vi.fn(), - runChannelLogin: vi.fn(), - runChannelLogout: vi.fn(), - runTui: vi.fn(), - loadAndMaybeMigrateDoctorConfig: vi.fn(), - ensureConfigReady: vi.fn(), - ensurePluginRegistryLoaded: vi.fn(), - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), - }, -})); +const programMocks = vi.hoisted(() => { + const setupWizardCommand = vi.fn(); + return { + messageCommand: vi.fn(), + statusCommand: vi.fn(), + configureCommand: vi.fn(), + configureCommandWithSections: vi.fn(), + setupCommand: vi.fn(), + setupWizardCommand, + onboardCommand: setupWizardCommand, + callGateway: vi.fn(), + runChannelLogin: vi.fn(), + runChannelLogout: vi.fn(), + runTui: vi.fn(), + loadAndMaybeMigrateDoctorConfig: vi.fn(), + ensureConfigReady: vi.fn(), + ensurePluginRegistryLoaded: vi.fn(), + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), + }, + }; +}); export const messageCommand = programMocks.messageCommand as AnyMock; export const statusCommand = programMocks.statusCommand as AnyMock; export const configureCommand = programMocks.configureCommand as AnyMock; export const configureCommandWithSections = programMocks.configureCommandWithSections as AnyMock; export const setupCommand = programMocks.setupCommand as AnyMock; +export const setupWizardCommand = programMocks.setupWizardCommand as AnyMock; export const onboardCommand = programMocks.onboardCommand as AnyMock; export const callGateway = programMocks.callGateway as AnyMock; export const runChannelLogin = programMocks.runChannelLogin as AnyMock; @@ -71,7 +76,10 @@ vi.mock("../commands/configure.js", () => ({ }, })); vi.mock("../commands/setup.js", () => ({ setupCommand: programMocks.setupCommand })); -vi.mock("../commands/onboard.js", () => ({ onboardCommand: programMocks.onboardCommand })); +vi.mock("../commands/onboard.js", () => ({ + onboardCommand: programMocks.onboardCommand, + setupWizardCommand: programMocks.setupWizardCommand, +})); vi.mock("../runtime.js", () => ({ defaultRuntime: programMocks.runtime })); vi.mock("./channel-auth.js", () => ({ runChannelLogin: programMocks.runChannelLogin, From e627a5069ff1b2f9d08a63acb323ed0b4de118cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 22:23:19 -0700 Subject: [PATCH 003/133] refactor(plugins): move auth profile hooks into providers --- docs/concepts/model-providers.md | 29 ++-- docs/tools/plugin.md | 76 ++++++---- extensions/anthropic/index.test.ts | 33 ++++ extensions/anthropic/index.ts | 46 +++++- extensions/copilot-proxy/index.ts | 8 + extensions/github-copilot/index.ts | 60 +++++++- extensions/google/gemini-cli-provider.test.ts | 16 ++ extensions/google/gemini-cli-provider.ts | 41 +++++ extensions/openai/openai-codex-provider.ts | 31 ++++ extensions/openai/openai-codex.test.ts | 28 +++- extensions/qwen-portal-auth/index.test.ts | 32 ++++ extensions/qwen-portal-auth/index.ts | 16 ++ src/agents/auth-profiles/doctor.ts | 28 +++- ...auth.openai-codex-refresh-fallback.test.ts | 31 +++- src/agents/auth-profiles/oauth.ts | 115 ++++++-------- .../auth-choice.apply.api-key-providers.ts | 55 +++++++ src/commands/auth-choice.apply.byteplus.ts | 46 ------ .../auth-choice.apply.copilot-proxy.ts | 14 -- .../auth-choice.apply.github-copilot.ts | 63 -------- ...uth-choice.apply.google-gemini-cli.test.ts | 86 ----------- .../auth-choice.apply.google-gemini-cli.ts | 37 ----- src/commands/auth-choice.apply.qwen-portal.ts | 14 -- src/commands/auth-choice.apply.ts | 14 -- ...h-choice.apply.volcengine-byteplus.test.ts | 141 ------------------ src/commands/auth-choice.apply.volcengine.ts | 46 ------ src/commands/auth-choice.apply.xai.ts | 65 -------- src/commands/auth-choice.test.ts | 27 ++++ src/plugin-sdk/core.ts | 1 + src/plugin-sdk/index.ts | 1 + src/plugins/provider-runtime.runtime.ts | 3 + src/plugins/provider-runtime.test.ts | 107 +++++++++---- src/plugins/provider-runtime.ts | 54 ++++--- src/plugins/types.ts | 41 +++++ 33 files changed, 712 insertions(+), 693 deletions(-) create mode 100644 extensions/qwen-portal-auth/index.test.ts delete mode 100644 src/commands/auth-choice.apply.byteplus.ts delete mode 100644 src/commands/auth-choice.apply.copilot-proxy.ts delete mode 100644 src/commands/auth-choice.apply.github-copilot.ts delete mode 100644 src/commands/auth-choice.apply.google-gemini-cli.test.ts delete mode 100644 src/commands/auth-choice.apply.google-gemini-cli.ts delete mode 100644 src/commands/auth-choice.apply.qwen-portal.ts delete mode 100644 src/commands/auth-choice.apply.volcengine-byteplus.test.ts delete mode 100644 src/commands/auth-choice.apply.volcengine.ts delete mode 100644 src/commands/auth-choice.apply.xai.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 8767d108132..8e8f17f4a67 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -25,7 +25,8 @@ For model selection rules, see [/concepts/models](/concepts/models). cases such as Anthropic API-key-first onboarding. - Provider plugins can also own provider runtime behavior via `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, - `capabilities`, `prepareExtraParams`, `wrapStreamFn`, + `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `formatApiKey`, + `refreshOAuth`, `buildAuthDoctorHint`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `isBinaryThinking`, `supportsXHighThinking`, `resolveDefaultThinkingLevel`, @@ -52,6 +53,12 @@ Typical split: - `capabilities`: provider publishes transcript/tooling/provider-family quirks - `prepareExtraParams`: provider defaults or normalizes per-model request params - `wrapStreamFn`: provider applies request headers/body/model compat wrappers +- `formatApiKey`: provider formats stored auth profiles into the runtime + `apiKey` string expected by the transport +- `refreshOAuth`: provider owns OAuth refresh when the shared `pi-ai` + refreshers are not enough +- `buildAuthDoctorHint`: provider appends repair guidance when OAuth refresh + fails - `isCacheTtlEligible`: provider decides which upstream model ids support prompt-cache TTL - `buildMissingAuthMessage`: provider replaces the generic auth-store error with a provider-specific recovery hint @@ -73,19 +80,21 @@ Typical split: Current bundled examples: -- `anthropic`: Claude 4.6 forward-compat fallback, usage endpoint fetching, - and cache-TTL/provider-family metadata +- `anthropic`: Claude 4.6 forward-compat fallback, auth repair hints, usage + endpoint fetching, and cache-TTL/provider-family metadata - `openrouter`: pass-through model ids, request wrappers, provider capability hints, and cache-TTL policy -- `github-copilot`: forward-compat model fallback, Claude-thinking transcript - hints, runtime token exchange, and usage endpoint fetching +- `github-copilot`: onboarding/device login, forward-compat model fallback, + Claude-thinking transcript hints, runtime token exchange, and usage endpoint + fetching - `openai`: GPT-5.4 forward-compat fallback, direct OpenAI transport normalization, Codex-aware missing-auth hints, Spark suppression, synthetic OpenAI/Codex catalog rows, thinking/live-model policy, and provider-family metadata - `google` and `google-gemini-cli`: Gemini 3.1 forward-compat fallback and - modern-model matching; Gemini CLI OAuth also owns usage-token parsing and - quota endpoint fetching for usage surfaces + modern-model matching; Gemini CLI OAuth also owns auth-profile token + formatting, usage-token parsing, and quota endpoint fetching for usage + surfaces - `moonshot`: shared transport, plugin-owned thinking payload normalization - `kilocode`: shared transport, plugin-owned request headers, reasoning payload normalization, Gemini transcript hints, and cache-TTL policy @@ -93,9 +102,9 @@ Current bundled examples: policy, binary-thinking/live-model policy, and usage auth + quota fetching - `mistral`, `opencode`, and `opencode-go`: plugin-owned capability metadata - `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`, - `minimax-portal`, `modelstudio`, `nvidia`, `qianfan`, `qwen-portal`, - `synthetic`, `together`, `venice`, `vercel-ai-gateway`, and `volcengine`: - plugin-owned catalogs only + `modelstudio`, `nvidia`, `qianfan`, `synthetic`, `together`, `venice`, + `vercel-ai-gateway`, and `volcengine`: plugin-owned catalogs only +- `qwen-portal`: plugin-owned catalog, OAuth login, and OAuth refresh - `minimax` and `xiaomi`: plugin-owned catalogs plus usage auth/snapshot logic The bundled `openai` plugin now owns both provider ids: `openai` and diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 32b9850a221..560d25930d5 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -220,7 +220,7 @@ Provider plugins now have two layers: - manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before runtime load - config-time hooks: `catalog` / legacy `discovery` -- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `isBinaryThinking`, `supportsXHighThinking`, `resolveDefaultThinkingLevel`, `isModernModelRef`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` +- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `formatApiKey`, `refreshOAuth`, `buildAuthDoctorHint`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `isBinaryThinking`, `supportsXHighThinking`, `resolveDefaultThinkingLevel`, `isModernModelRef`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` OpenClaw still owns the generic agent loop, failover, transcript handling, and tool policy. These hooks are the seam for provider-specific behavior without @@ -254,31 +254,39 @@ For model/provider plugins, OpenClaw uses hooks in this rough order: Provider-owned request-param normalization before generic stream option wrappers. 8. `wrapStreamFn` Provider-owned stream wrapper after generic wrappers are applied. -9. `isCacheTtlEligible` - Provider-owned prompt-cache policy for proxy/backhaul providers. -10. `buildMissingAuthMessage` +9. `formatApiKey` + Provider-owned auth-profile formatter used when a stored auth profile needs + to become the runtime `apiKey` string. +10. `refreshOAuth` + Provider-owned OAuth refresh override for custom refresh endpoints or + refresh-failure policy. +11. `buildAuthDoctorHint` + Provider-owned repair hint appended when OAuth refresh fails. +12. `isCacheTtlEligible` + Provider-owned prompt-cache policy for proxy/backhaul providers. +13. `buildMissingAuthMessage` Provider-owned replacement for the generic missing-auth recovery message. -11. `suppressBuiltInModel` +14. `suppressBuiltInModel` Provider-owned stale upstream model suppression plus optional user-facing error hint. -12. `augmentModelCatalog` +15. `augmentModelCatalog` Provider-owned synthetic/final catalog rows appended after discovery. -13. `isBinaryThinking` +16. `isBinaryThinking` Provider-owned on/off reasoning toggle for binary-thinking providers. -14. `supportsXHighThinking` +17. `supportsXHighThinking` Provider-owned `xhigh` reasoning support for selected models. -15. `resolveDefaultThinkingLevel` +18. `resolveDefaultThinkingLevel` Provider-owned default `/think` level for a specific model family. -16. `isModernModelRef` +19. `isModernModelRef` Provider-owned modern-model matcher used by live profile filters and smoke selection. -17. `prepareRuntimeAuth` +20. `prepareRuntimeAuth` Exchanges a configured credential into the actual runtime token/key just before inference. -18. `resolveUsageAuth` +21. `resolveUsageAuth` Resolves usage/billing credentials for `/usage` and related status surfaces. -19. `fetchUsageSnapshot` +22. `fetchUsageSnapshot` Fetches and normalizes provider-specific usage/quota snapshots after auth is resolved. @@ -291,6 +299,9 @@ For model/provider plugins, OpenClaw uses hooks in this rough order: - `capabilities`: publish provider-family and transcript/tooling quirks without hardcoding provider ids in core - `prepareExtraParams`: set provider defaults or normalize provider-specific per-model params before generic stream wrapping - `wrapStreamFn`: add provider-specific headers/payload/model compat patches while still using the normal `pi-ai` execution path +- `formatApiKey`: turn a stored auth profile into the runtime `apiKey` string without hardcoding provider token blobs in core +- `refreshOAuth`: own OAuth refresh for providers that do not fit the shared `pi-ai` refreshers +- `buildAuthDoctorHint`: append provider-owned auth repair guidance when refresh fails - `isCacheTtlEligible`: decide whether provider/model pairs should use cache TTL metadata - `buildMissingAuthMessage`: replace the generic auth-store error with a provider-specific recovery hint - `suppressBuiltInModel`: hide stale upstream rows and optionally return a provider-owned error for direct resolution failures @@ -312,6 +323,9 @@ Rule of thumb: - provider needs transcript/provider-family quirks: use `capabilities` - provider needs default request params or per-provider param cleanup: use `prepareExtraParams` - provider needs request headers/body/model compat wrappers without a custom transport: use `wrapStreamFn` +- provider stores extra metadata in auth profiles and needs a custom runtime token shape: use `formatApiKey` +- provider needs a custom OAuth refresh endpoint or refresh failure policy: use `refreshOAuth` +- provider needs provider-owned auth repair guidance after refresh failure: use `buildAuthDoctorHint` - provider needs proxy-specific cache TTL gating: use `isCacheTtlEligible` - provider needs a provider-specific missing-auth recovery hint: use `buildMissingAuthMessage` - provider needs to hide stale upstream rows or replace them with a vendor hint: use `suppressBuiltInModel` @@ -384,11 +398,12 @@ api.registerProvider({ ### Built-in examples -- Anthropic uses `resolveDynamicModel`, `capabilities`, `resolveUsageAuth`, - `fetchUsageSnapshot`, `isCacheTtlEligible`, `resolveDefaultThinkingLevel`, - and `isModernModelRef` because it owns Claude 4.6 forward-compat, - provider-family hints, usage endpoint integration, prompt-cache - eligibility, and Claude default/adaptive thinking policy. +- Anthropic uses `resolveDynamicModel`, `capabilities`, `buildAuthDoctorHint`, + `resolveUsageAuth`, `fetchUsageSnapshot`, `isCacheTtlEligible`, + `resolveDefaultThinkingLevel`, and `isModernModelRef` because it owns Claude + 4.6 forward-compat, provider-family hints, auth repair guidance, usage + endpoint integration, prompt-cache eligibility, and Claude default/adaptive + thinking policy. - OpenAI uses `resolveDynamicModel`, `normalizeResolvedModel`, and `capabilities` plus `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `supportsXHighThinking`, and `isModernModelRef` @@ -399,20 +414,22 @@ api.registerProvider({ - OpenRouter uses `catalog` plus `resolveDynamicModel` and `prepareDynamicModel` because the provider is pass-through and may expose new model ids before OpenClaw's static catalog updates. -- GitHub Copilot uses `catalog`, `resolveDynamicModel`, and +- GitHub Copilot uses `catalog`, `auth`, `resolveDynamicModel`, and `capabilities` plus `prepareRuntimeAuth` and `fetchUsageSnapshot` because it - needs model fallback behavior, Claude transcript quirks, a GitHub token -> - Copilot token exchange, and a provider-owned usage endpoint. + needs provider-owned device login, model fallback behavior, Claude transcript + quirks, a GitHub token -> Copilot token exchange, and a provider-owned usage + endpoint. - OpenAI Codex uses `catalog`, `resolveDynamicModel`, - `normalizeResolvedModel`, and `augmentModelCatalog` plus + `normalizeResolvedModel`, `refreshOAuth`, and `augmentModelCatalog` plus `prepareExtraParams`, `resolveUsageAuth`, and `fetchUsageSnapshot` because it still runs on core OpenAI transports but owns its transport/base URL - normalization, default transport choice, synthetic Codex catalog rows, and - ChatGPT usage endpoint integration. + normalization, OAuth refresh fallback policy, default transport choice, + synthetic Codex catalog rows, and ChatGPT usage endpoint integration. - Google AI Studio and Gemini CLI OAuth use `resolveDynamicModel` and `isModernModelRef` because they own Gemini 3.1 forward-compat fallback and - modern-model matching; Gemini CLI OAuth also uses `resolveUsageAuth` and - `fetchUsageSnapshot` for token parsing and quota endpoint wiring. + modern-model matching; Gemini CLI OAuth also uses `formatApiKey`, + `resolveUsageAuth`, and `fetchUsageSnapshot` for token formatting, token + parsing, and quota endpoint wiring. - OpenRouter uses `capabilities`, `wrapStreamFn`, and `isCacheTtlEligible` to keep provider-specific request headers, routing metadata, reasoning patches, and prompt-cache policy out of core. @@ -430,9 +447,10 @@ api.registerProvider({ - Mistral, OpenCode Zen, and OpenCode Go use `capabilities` only to keep transcript/tooling quirks out of core. - Catalog-only bundled providers such as `byteplus`, `cloudflare-ai-gateway`, - `huggingface`, `kimi-coding`, `minimax-portal`, `modelstudio`, `nvidia`, - `qianfan`, `qwen-portal`, `synthetic`, `together`, `venice`, - `vercel-ai-gateway`, and `volcengine` use `catalog` only. + `huggingface`, `kimi-coding`, `modelstudio`, `nvidia`, `qianfan`, + `synthetic`, `together`, `venice`, `vercel-ai-gateway`, and `volcengine` use + `catalog` only. +- Qwen portal uses `catalog`, `auth`, and `refreshOAuth`. - MiniMax and Xiaomi use `catalog` plus usage hooks because their `/usage` behavior is plugin-owned even though inference still runs through the shared transports. diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index 172a7099e4d..0bfa830f14f 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -58,6 +58,39 @@ describe("anthropic plugin", () => { }); }); + it("owns auth doctor hint generation", () => { + const provider = registerProvider(); + const hint = provider.buildAuthDoctorHint?.({ + provider: "anthropic", + profileId: "anthropic:default", + config: { + auth: { + profiles: { + "anthropic:default": { + provider: "anthropic", + mode: "oauth", + }, + }, + }, + } as never, + store: { + version: 1, + profiles: { + "anthropic:oauth-user@example.com": { + type: "oauth", + provider: "anthropic", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }, + }, + }); + + expect(hint).toContain("suggested profile: anthropic:oauth-user@example.com"); + expect(hint).toContain("openclaw doctor --yes"); + }); + it("owns usage snapshot fetching", async () => { const provider = registerProvider(); const mockFetch = createProviderUsageFetch(async (url) => { diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index 84d584e554f..ba2a1a55cb5 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -5,8 +5,11 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { upsertAuthProfile } from "../../src/agents/auth-profiles.js"; +import { 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 { normalizeSecretInputModeInput, @@ -120,6 +123,41 @@ function matchesAnthropicModernModel(modelId: string): boolean { return ANTHROPIC_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix)); } +function buildAnthropicAuthDoctorHint(params: { + config?: ProviderAuthContext["config"]; + store: AuthProfileStore; + profileId?: string; +}): string { + const legacyProfileId = params.profileId ?? "anthropic:default"; + const suggested = suggestOAuthProfileIdForLegacyDefault({ + cfg: params.config, + store: params.store, + provider: PROVIDER_ID, + legacyProfileId, + }); + if (!suggested || suggested === legacyProfileId) { + return ""; + } + + const storeOauthProfiles = listProfilesForProvider(params.store, PROVIDER_ID) + .filter((id) => params.store.profiles[id]?.type === "oauth") + .join(", "); + + const cfgMode = params.config?.auth?.profiles?.[legacyProfileId]?.mode; + const cfgProvider = params.config?.auth?.profiles?.[legacyProfileId]?.provider; + + return [ + "Doctor hint (for GitHub issue):", + `- provider: ${PROVIDER_ID}`, + `- config: ${legacyProfileId}${ + cfgProvider || cfgMode ? ` (provider=${cfgProvider ?? "?"}, mode=${cfgMode ?? "?"})` : "" + }`, + `- auth store oauth profiles: ${storeOauthProfiles || "(none)"}`, + `- suggested profile: ${suggested}`, + `Fix: run "${formatCliCommand("openclaw doctor --yes")}"`, + ].join("\n"); +} + async function runAnthropicSetupToken(ctx: ProviderAuthContext): Promise { await ctx.prompter.note( ["Run `claude setup-token` in your terminal.", "Then paste the generated token below."].join( @@ -311,6 +349,12 @@ const anthropicPlugin = { fetchUsageSnapshot: async (ctx) => await fetchClaudeUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), isCacheTtlEligible: () => true, + buildAuthDoctorHint: (ctx) => + buildAnthropicAuthDoctorHint({ + config: ctx.config, + store: ctx.store, + profileId: ctx.profileId, + }), }); }, }; diff --git a/extensions/copilot-proxy/index.ts b/extensions/copilot-proxy/index.ts index 6fad48228cd..2c517d9c26c 100644 --- a/extensions/copilot-proxy/index.ts +++ b/extensions/copilot-proxy/index.ts @@ -147,6 +147,14 @@ const copilotProxyPlugin = { }, }, ], + wizard: { + setup: { + choiceId: "copilot-proxy", + choiceLabel: "Copilot Proxy", + choiceHint: "Configure base URL + model ids", + methodId: "local", + }, + }, }); }, }; diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 41c9deed5ec..8dadad31903 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi, + type ProviderAuthContext, type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; @@ -8,6 +9,7 @@ import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles 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 { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js"; import { fetchCopilotUsage } from "./usage.js"; @@ -72,6 +74,46 @@ function resolveCopilotForwardCompatModel( return undefined; } +async function runGitHubCopilotAuth(ctx: ProviderAuthContext) { + await ctx.prompter.note( + [ + "This will open a GitHub device login to authorize Copilot.", + "Requires an active GitHub Copilot subscription.", + ].join("\n"), + "GitHub Copilot", + ); + + if (!process.stdin.isTTY) { + await ctx.prompter.note("GitHub Copilot login requires an interactive TTY.", "GitHub Copilot"); + return { profiles: [] }; + } + + try { + await githubCopilotLoginCommand({ yes: true, profileId: "github-copilot:github" }, ctx.runtime); + } catch (err) { + await ctx.prompter.note(`GitHub Copilot login failed: ${String(err)}`, "GitHub Copilot"); + return { profiles: [] }; + } + + const authStore = ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, + }); + const credential = authStore.profiles["github-copilot:github"]; + if (!credential || credential.type !== "token") { + return { profiles: [] }; + } + + return { + profiles: [ + { + profileId: "github-copilot:github", + credential, + }, + ], + defaultModel: "github-copilot/gpt-4o", + }; +} + const githubCopilotPlugin = { id: "github-copilot", name: "GitHub Copilot Provider", @@ -83,7 +125,23 @@ const githubCopilotPlugin = { label: "GitHub Copilot", docsPath: "/providers/models", envVars: COPILOT_ENV_VARS, - auth: [], + auth: [ + { + id: "device", + label: "GitHub device login", + hint: "Browser device-code flow", + kind: "device_code", + run: async (ctx) => await runGitHubCopilotAuth(ctx), + }, + ], + wizard: { + setup: { + choiceId: "github-copilot", + choiceLabel: "GitHub Copilot", + choiceHint: "Device login with your GitHub account", + methodId: "device", + }, + }, catalog: { order: "late", run: async (ctx) => { diff --git a/extensions/google/gemini-cli-provider.test.ts b/extensions/google/gemini-cli-provider.test.ts index 21e7f505521..75619863b4b 100644 --- a/extensions/google/gemini-cli-provider.test.ts +++ b/extensions/google/gemini-cli-provider.test.ts @@ -146,6 +146,22 @@ describe("google plugin", () => { }); }); + it("owns OAuth auth-profile formatting", () => { + const { providers } = registerGooglePlugin(); + const provider = findProvider(providers, "google-gemini-cli"); + + expect( + provider.formatApiKey?.({ + type: "oauth", + provider: "google-gemini-cli", + access: "google-oauth-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + projectId: "proj-123", + }), + ).toBe('{"token":"google-oauth-token","projectId":"proj-123"}'); + }); + it("owns usage snapshot fetching", async () => { const { providers } = registerGooglePlugin(); const provider = findProvider(providers, "google-gemini-cli"); diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index 5a3d784a866..926913f7390 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -30,6 +30,20 @@ function parseGoogleUsageToken(apiKey: string): string { return apiKey; } +function formatGoogleOauthApiKey(cred: { + type?: string; + access?: string; + projectId?: string; +}): string { + if (cred.type !== "oauth" || typeof cred.access !== "string" || !cred.access.trim()) { + return ""; + } + return JSON.stringify({ + token: cred.access, + projectId: cred.projectId, + }); +} + async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); } @@ -48,6 +62,24 @@ export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { hint: "PKCE + localhost callback", kind: "oauth", run: async (ctx: ProviderAuthContext) => { + await ctx.prompter.note( + [ + "This is an unofficial integration and is not endorsed by Google.", + "Some users have reported account restrictions or suspensions after using third-party Gemini CLI and Antigravity OAuth clients.", + "Proceed only if you understand and accept this risk.", + ].join("\n"), + "Google Gemini CLI caution", + ); + + const proceed = await ctx.prompter.confirm({ + message: "Continue with Google Gemini CLI OAuth?", + initialValue: false, + }); + if (!proceed) { + await ctx.prompter.note("Skipped Google Gemini CLI OAuth setup.", "Setup skipped"); + return { profiles: [] }; + } + const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…"); try { const result = await loginGeminiCliOAuth({ @@ -81,9 +113,18 @@ export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { }, }, ], + wizard: { + setup: { + choiceId: "google-gemini-cli", + choiceLabel: "Gemini CLI OAuth", + choiceHint: "Google OAuth with project-aware token payload", + methodId: "oauth", + }, + }, resolveDynamicModel: (ctx) => resolveGoogle31ForwardCompatModel({ providerId: PROVIDER_ID, ctx }), isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), + formatApiKey: (cred) => formatGoogleOauthApiKey(cred), resolveUsageAuth: async (ctx) => { const auth = await ctx.resolveOAuthToken(); if (!auth) { diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index a11902608ac..17ee1348de2 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -1,3 +1,4 @@ +import { getOAuthApiKey } from "@mariozechner/pi-ai/oauth"; import type { ProviderAuthContext, ProviderResolveDynamicModelContext, @@ -5,6 +6,7 @@ import type { } from "openclaw/plugin-sdk/core"; 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/model-selection.js"; @@ -132,6 +134,34 @@ function resolveCodexForwardCompatModel( ); } +async function refreshOpenAICodexOAuthCredential(cred: OAuthCredential) { + try { + const refreshed = await getOAuthApiKey("openai-codex", { + "openai-codex": cred, + }); + if (!refreshed) { + throw new Error("OpenAI Codex OAuth refresh returned no credentials."); + } + return { + ...cred, + ...refreshed.newCredentials, + type: "oauth" as const, + provider: PROVIDER_ID, + email: cred.email, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + /extract\s+accountid\s+from\s+token/i.test(message) && + typeof cred.access === "string" && + cred.access.trim().length > 0 + ) { + return cred; + } + throw error; + } +} + async function runOpenAICodexOAuth(ctx: ProviderAuthContext) { let creds; try { @@ -221,6 +251,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), fetchUsageSnapshot: async (ctx) => await fetchCodexUsage(ctx.token, ctx.accountId, ctx.timeoutMs, ctx.fetchFn), + refreshOAuth: async (cred) => await refreshOpenAICodexOAuthCredential(cred), augmentModelCatalog: (ctx) => { const gpt54Template = findCatalogTemplate({ entries: ctx.entries, diff --git a/extensions/openai/openai-codex.test.ts b/extensions/openai/openai-codex.test.ts index bbf77320b26..1e9efd3bfbc 100644 --- a/extensions/openai/openai-codex.test.ts +++ b/extensions/openai/openai-codex.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ProviderPlugin } from "../../src/plugins/types.js"; import { createProviderUsageFetch, @@ -6,6 +6,16 @@ import { } from "../../src/test-utils/provider-usage-fetch.js"; import openAIPlugin from "./index.js"; +const getOAuthApiKeyMock = vi.hoisted(() => vi.fn()); + +vi.mock("@mariozechner/pi-ai/oauth", async () => { + const actual = await vi.importActual("@mariozechner/pi-ai/oauth"); + return { + ...actual, + getOAuthApiKey: getOAuthApiKeyMock, + }; +}); + function registerCodexProvider(): ProviderPlugin { let provider: ProviderPlugin | undefined; openAIPlugin.register({ @@ -22,6 +32,22 @@ function registerCodexProvider(): ProviderPlugin { } describe("openai codex provider", () => { + it("owns refresh fallback for accountId extraction failures", async () => { + const provider = registerCodexProvider(); + const credential = { + type: "oauth" as const, + provider: "openai-codex", + access: "cached-access-token", + refresh: "refresh-token", + expires: Date.now() - 60_000, + }; + + getOAuthApiKeyMock.mockReset(); + getOAuthApiKeyMock.mockRejectedValueOnce(new Error("Failed to extract accountId from token")); + + await expect(provider.refreshOAuth?.(credential)).resolves.toEqual(credential); + }); + it("owns forward-compat codex models", () => { const provider = registerCodexProvider(); const model = provider.resolveDynamicModel?.({ diff --git a/extensions/qwen-portal-auth/index.test.ts b/extensions/qwen-portal-auth/index.test.ts new file mode 100644 index 00000000000..f5e7f599f78 --- /dev/null +++ b/extensions/qwen-portal-auth/index.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; +import { registerSingleProviderPlugin } from "../../src/test-utils/plugin-registration.js"; +import qwenPortalPlugin from "./index.js"; + +const refreshQwenPortalCredentialsMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../src/providers/qwen-portal-oauth.js", () => ({ + refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock, +})); + +describe("qwen portal plugin", () => { + it("owns OAuth refresh", async () => { + const provider = registerSingleProviderPlugin(qwenPortalPlugin); + const credential = { + type: "oauth" as const, + provider: "qwen-portal", + access: "stale-access-token", + refresh: "refresh-token", + expires: Date.now() - 60_000, + }; + const refreshed = { + ...credential, + access: "fresh-access-token", + expires: Date.now() + 60_000, + }; + + refreshQwenPortalCredentialsMock.mockReset(); + refreshQwenPortalCredentialsMock.mockResolvedValueOnce(refreshed); + + await expect(provider.refreshOAuth?.(credential)).resolves.toEqual(refreshed); + }); +}); diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index 446070b0a6b..774b1329acf 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -7,6 +7,7 @@ import { } 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 { loginQwenPortalOAuth } from "./oauth.js"; const PROVIDER_ID = "qwen-portal"; @@ -157,6 +158,21 @@ const qwenPortalPlugin = { }, }, ], + wizard: { + setup: { + choiceId: "qwen-portal", + choiceLabel: "Qwen OAuth", + choiceHint: "Device code login", + methodId: "device", + }, + }, + refreshOAuth: async (cred) => ({ + ...cred, + ...(await refreshQwenPortalCredentials(cred)), + type: "oauth", + provider: PROVIDER_ID, + email: cred.email, + }), }); }, }; diff --git a/src/agents/auth-profiles/doctor.ts b/src/agents/auth-profiles/doctor.ts index ee743a06000..220d20f2294 100644 --- a/src/agents/auth-profiles/doctor.ts +++ b/src/agents/auth-profiles/doctor.ts @@ -5,12 +5,36 @@ import { listProfilesForProvider } from "./profiles.js"; import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; import type { AuthProfileStore } from "./types.js"; -export function formatAuthDoctorHint(params: { +let providerRuntimePromise: + | Promise + | undefined; + +function loadProviderRuntime() { + providerRuntimePromise ??= import("../../plugins/provider-runtime.runtime.js"); + return providerRuntimePromise; +} + +export async function formatAuthDoctorHint(params: { cfg?: OpenClawConfig; store: AuthProfileStore; provider: string; profileId?: string; -}): string { +}): Promise { + const normalizedProvider = normalizeProviderId(params.provider); + const { buildProviderAuthDoctorHintWithPlugin } = await loadProviderRuntime(); + const pluginHint = await buildProviderAuthDoctorHintWithPlugin({ + provider: normalizedProvider, + context: { + config: params.cfg, + store: params.store, + provider: normalizedProvider, + profileId: params.profileId, + }, + }); + if (typeof pluginHint === "string" && pluginHint.trim()) { + return pluginHint; + } + const providerKey = normalizeProviderId(params.provider); if (providerKey !== "anthropic") { return ""; diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index 23381d89a05..eff06553752 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -17,6 +17,18 @@ const { getOAuthApiKeyMock } = vi.hoisted(() => ({ }), })); +const { + refreshProviderOAuthCredentialWithPluginMock, + formatProviderAuthProfileApiKeyWithPluginMock, + buildProviderAuthDoctorHintWithPluginMock, +} = vi.hoisted(() => ({ + refreshProviderOAuthCredentialWithPluginMock: vi.fn( + async (_params?: { context?: unknown }) => undefined, + ), + formatProviderAuthProfileApiKeyWithPluginMock: vi.fn(() => undefined), + buildProviderAuthDoctorHintWithPluginMock: vi.fn(async () => undefined), +})); + vi.mock("@mariozechner/pi-ai/oauth", () => ({ getOAuthApiKey: getOAuthApiKeyMock, getOAuthProviders: () => [ @@ -25,6 +37,12 @@ vi.mock("@mariozechner/pi-ai/oauth", () => ({ ], })); +vi.mock("../../plugins/provider-runtime.runtime.js", () => ({ + refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock, + formatProviderAuthProfileApiKeyWithPlugin: formatProviderAuthProfileApiKeyWithPluginMock, + buildProviderAuthDoctorHintWithPlugin: buildProviderAuthDoctorHintWithPluginMock, +})); + function createExpiredOauthStore(params: { profileId: string; provider: string; @@ -55,6 +73,12 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { beforeEach(async () => { getOAuthApiKeyMock.mockClear(); + refreshProviderOAuthCredentialWithPluginMock.mockReset(); + refreshProviderOAuthCredentialWithPluginMock.mockResolvedValue(undefined); + formatProviderAuthProfileApiKeyWithPluginMock.mockReset(); + formatProviderAuthProfileApiKeyWithPluginMock.mockReturnValue(undefined); + buildProviderAuthDoctorHintWithPluginMock.mockReset(); + buildProviderAuthDoctorHintWithPluginMock.mockResolvedValue(undefined); clearRuntimeAuthProfileStoreSnapshots(); tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-refresh-fallback-")); agentDir = path.join(tempRoot, "agents", "main", "agent"); @@ -72,6 +96,9 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { it("falls back to cached access token when openai-codex refresh fails on accountId extraction", async () => { const profileId = "openai-codex:default"; + refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce( + async (params?: { context?: unknown }) => params?.context as never, + ); saveAuthProfileStore( createExpiredOauthStore({ profileId, @@ -91,7 +118,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { provider: "openai-codex", email: undefined, }); - expect(getOAuthApiKeyMock).toHaveBeenCalledTimes(1); + expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1); }); it("keeps throwing for non-codex providers on the same refresh error", async () => { @@ -122,7 +149,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { }), agentDir, ); - getOAuthApiKeyMock.mockImplementationOnce(async () => { + refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(async () => { throw new Error("invalid_grant"); }); diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index edc1ddfb24e..f09b972fd36 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -7,20 +7,27 @@ import { import { loadConfig, type OpenClawConfig } from "../../config/config.js"; import { coerceSecretRef } from "../../config/types.secrets.js"; import { withFileLock } from "../../infra/file-lock.js"; -import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js"; import { resolveSecretRefString, type SecretRefResolveCache } from "../../secrets/resolve.js"; import { refreshChutesTokens } from "../chutes-oauth.js"; -import { normalizeProviderId } from "../model-selection.js"; import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js"; import { resolveTokenExpiryState } from "./credential-state.js"; import { formatAuthDoctorHint } from "./doctor.js"; import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js"; import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js"; -import type { AuthProfileStore } from "./types.js"; +import type { AuthProfileStore, OAuthCredential } from "./types.js"; const OAUTH_PROVIDER_IDS = new Set(getOAuthProviders().map((provider) => provider.id)); +let providerRuntimePromise: + | Promise + | undefined; + +function loadProviderRuntime() { + providerRuntimePromise ??= import("../../plugins/provider-runtime.runtime.js"); + return providerRuntimePromise; +} + const isOAuthProvider = (provider: string): provider is OAuthProvider => OAUTH_PROVIDER_IDS.has(provider); @@ -58,14 +65,13 @@ function isProfileConfigCompatible(params: { return true; } -function buildOAuthApiKey(provider: string, credentials: OAuthCredentials): string { - const needsProjectId = provider === "google-gemini-cli"; - return needsProjectId - ? JSON.stringify({ - token: credentials.access, - projectId: credentials.projectId, - }) - : credentials.access; +async function buildOAuthApiKey(provider: string, credentials: OAuthCredential): Promise { + const { formatProviderAuthProfileApiKeyWithPlugin } = await loadProviderRuntime(); + const formatted = formatProviderAuthProfileApiKeyWithPlugin({ + provider, + context: credentials, + }); + return typeof formatted === "string" && formatted.length > 0 ? formatted : credentials.access; } function buildApiKeyProfileResult(params: { apiKey: string; provider: string; email?: string }) { @@ -76,13 +82,13 @@ function buildApiKeyProfileResult(params: { apiKey: string; provider: string; em }; } -function buildOAuthProfileResult(params: { +async function buildOAuthProfileResult(params: { provider: string; - credentials: OAuthCredentials; + credentials: OAuthCredential; email?: string; }) { return buildApiKeyProfileResult({ - apiKey: buildOAuthApiKey(params.provider, params.credentials), + apiKey: await buildOAuthApiKey(params.provider, params.credentials), provider: params.provider, email: params.email, }); @@ -92,23 +98,6 @@ function extractErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function shouldUseOpenaiCodexRefreshFallback(params: { - provider: string; - credentials: OAuthCredentials; - error: unknown; -}): boolean { - if (normalizeProviderId(params.provider) !== "openai-codex") { - return false; - } - const message = extractErrorMessage(params.error); - if (!/extract\s+accountid\s+from\s+token/i.test(message)) { - return false; - } - return ( - typeof params.credentials.access === "string" && params.credentials.access.trim().length > 0 - ); -} - type ResolveApiKeyForProfileParams = { cfg?: OpenClawConfig; store: AuthProfileStore; @@ -171,15 +160,24 @@ async function refreshOAuthTokenWithLock(params: { if (Date.now() < cred.expires) { return { - apiKey: buildOAuthApiKey(cred.provider, cred), + apiKey: await buildOAuthApiKey(cred.provider, cred), newCredentials: cred, }; } - const oauthCreds: Record = { - [cred.provider]: cred, - }; + const { refreshProviderOAuthCredentialWithPlugin } = await loadProviderRuntime(); + const pluginRefreshed = await refreshProviderOAuthCredentialWithPlugin({ + provider: cred.provider, + context: cred, + }); + if (pluginRefreshed) { + return { + apiKey: await buildOAuthApiKey(cred.provider, pluginRefreshed), + newCredentials: pluginRefreshed, + }; + } + const oauthCreds: Record = { [cred.provider]: cred }; const result = String(cred.provider) === "chutes" ? await (async () => { @@ -188,18 +186,13 @@ async function refreshOAuthTokenWithLock(params: { }); return { apiKey: newCredentials.access, newCredentials }; })() - : String(cred.provider) === "qwen-portal" - ? await (async () => { - const newCredentials = await refreshQwenPortalCredentials(cred); - return { apiKey: newCredentials.access, newCredentials }; - })() - : await (async () => { - const oauthProvider = resolveOAuthProvider(cred.provider); - if (!oauthProvider) { - return null; - } - return await getOAuthApiKey(oauthProvider, oauthCreds); - })(); + : await (async () => { + const oauthProvider = resolveOAuthProvider(cred.provider); + if (!oauthProvider) { + return null; + } + return await getOAuthApiKey(oauthProvider, oauthCreds); + })(); if (!result) { return null; } @@ -234,7 +227,7 @@ async function tryResolveOAuthProfile( } if (Date.now() < cred.expires) { - return buildOAuthProfileResult({ + return await buildOAuthProfileResult({ provider: cred.provider, credentials: cred, email: cred.email, @@ -379,7 +372,7 @@ export async function resolveApiKeyForProfile( }) ?? cred; if (Date.now() < oauthCred.expires) { - return buildOAuthProfileResult({ + return await buildOAuthProfileResult({ provider: oauthCred.provider, credentials: oauthCred, email: oauthCred.email, @@ -403,7 +396,7 @@ export async function resolveApiKeyForProfile( const refreshedStore = ensureAuthProfileStore(params.agentDir); const refreshed = refreshedStore.profiles[profileId]; if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) { - return buildOAuthProfileResult({ + return await buildOAuthProfileResult({ provider: refreshed.provider, credentials: refreshed, email: refreshed.email ?? cred.email, @@ -445,7 +438,7 @@ export async function resolveApiKeyForProfile( agentDir: params.agentDir, expires: new Date(mainCred.expires).toISOString(), }); - return buildOAuthProfileResult({ + return await buildOAuthProfileResult({ provider: mainCred.provider, credentials: mainCred, email: mainCred.email, @@ -456,26 +449,8 @@ export async function resolveApiKeyForProfile( } } - if ( - shouldUseOpenaiCodexRefreshFallback({ - provider: cred.provider, - credentials: cred, - error, - }) - ) { - log.warn("openai-codex oauth refresh failed; using cached access token fallback", { - profileId, - provider: cred.provider, - }); - return buildApiKeyProfileResult({ - apiKey: cred.access, - provider: cred.provider, - email: cred.email, - }); - } - const message = extractErrorMessage(error); - const hint = formatAuthDoctorHint({ + const hint = await formatAuthDoctorHint({ cfg, store: refreshedStore, provider: cred.provider, diff --git a/src/commands/auth-choice.apply.api-key-providers.ts b/src/commands/auth-choice.apply.api-key-providers.ts index ac3690bf3cd..078dac8d0a1 100644 --- a/src/commands/auth-choice.apply.api-key-providers.ts +++ b/src/commands/auth-choice.apply.api-key-providers.ts @@ -3,6 +3,8 @@ import type { SecretInput } from "../config/types.secrets.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 { ensureModelAllowlistEntry } from "./model-allowlist.js"; +import { applyPrimaryModel } from "./model-picker.js"; import type { ApiKeyStorageOptions } from "./onboard-auth.credentials.js"; import { applyAuthProfileConfig, @@ -36,6 +38,8 @@ import { applyVeniceProviderConfig, applyVercelAiGatewayConfig, applyVercelAiGatewayProviderConfig, + applyXaiConfig, + applyXaiProviderConfig, applyXiaomiConfig, applyXiaomiProviderConfig, KILOCODE_DEFAULT_MODEL_REF, @@ -58,11 +62,15 @@ import { setTogetherApiKey, setVeniceApiKey, setVercelAiGatewayApiKey, + setVolcengineApiKey, + setByteplusApiKey, + setXaiApiKey, setXiaomiApiKey, SYNTHETIC_DEFAULT_MODEL_REF, TOGETHER_DEFAULT_MODEL_REF, VENICE_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + XAI_DEFAULT_MODEL_REF, XIAOMI_DEFAULT_MODEL_REF, } from "./onboard-auth.js"; import type { AuthChoice, SecretInputMode } from "./onboard-types.js"; @@ -114,6 +122,9 @@ type SimpleApiKeyProviderFlow = { noteTitle?: string; }; +const VOLCENGINE_DEFAULT_MODEL_REF = "volcengine-plan/ark-code-latest"; +const BYTEPLUS_DEFAULT_MODEL_REF = "byteplus-plan/ark-code-latest"; + const SIMPLE_API_KEY_PROVIDER_FLOWS: Partial> = { "ai-gateway-api-key": { provider: "vercel-ai-gateway", @@ -178,6 +189,18 @@ const SIMPLE_API_KEY_PROVIDER_FLOWS: Partial String(value ?? "").trim(), validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }, + "volcengine-api-key": { + provider: "volcengine", + profileId: "volcengine:default", + expectedProviders: ["volcengine"], + envLabel: "VOLCANO_ENGINE_API_KEY", + promptMessage: "Enter Volcano Engine API key", + setCredential: setVolcengineApiKey, + defaultModel: VOLCENGINE_DEFAULT_MODEL_REF, + applyDefaultConfig: (cfg) => applyPrimaryModel(cfg, VOLCENGINE_DEFAULT_MODEL_REF), + applyProviderConfig: (cfg) => + ensureModelAllowlistEntry({ + cfg, + modelRef: VOLCENGINE_DEFAULT_MODEL_REF, + }), + noteDefault: VOLCENGINE_DEFAULT_MODEL_REF, + }, + "byteplus-api-key": { + provider: "byteplus", + profileId: "byteplus:default", + expectedProviders: ["byteplus"], + envLabel: "BYTEPLUS_API_KEY", + promptMessage: "Enter BytePlus API key", + setCredential: setByteplusApiKey, + defaultModel: BYTEPLUS_DEFAULT_MODEL_REF, + applyDefaultConfig: (cfg) => applyPrimaryModel(cfg, BYTEPLUS_DEFAULT_MODEL_REF), + applyProviderConfig: (cfg) => + ensureModelAllowlistEntry({ + cfg, + modelRef: BYTEPLUS_DEFAULT_MODEL_REF, + }), + noteDefault: BYTEPLUS_DEFAULT_MODEL_REF, + }, "synthetic-api-key": { provider: "synthetic", profileId: "synthetic:default", diff --git a/src/commands/auth-choice.apply.byteplus.ts b/src/commands/auth-choice.apply.byteplus.ts deleted file mode 100644 index a3b0fea1d81..00000000000 --- a/src/commands/auth-choice.apply.byteplus.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; -import { - ensureApiKeyFromOptionEnvOrPrompt, - normalizeSecretInputModeInput, -} from "./auth-choice.apply-helpers.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyPrimaryModel } from "./model-picker.js"; -import { applyAuthProfileConfig, setByteplusApiKey } from "./onboard-auth.js"; - -/** Default model for BytePlus setup auth. */ -export const BYTEPLUS_DEFAULT_MODEL = "byteplus-plan/ark-code-latest"; - -export async function applyAuthChoiceBytePlus( - params: ApplyAuthChoiceParams, -): Promise { - if (params.authChoice !== "byteplus-api-key") { - return null; - } - - const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - await ensureApiKeyFromOptionEnvOrPrompt({ - token: params.opts?.byteplusApiKey, - tokenProvider: "byteplus", - secretInputMode: requestedSecretInputMode, - config: params.config, - expectedProviders: ["byteplus"], - provider: "byteplus", - envLabel: "BYTEPLUS_API_KEY", - promptMessage: "Enter BytePlus API key", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: params.prompter, - setCredential: async (apiKey, mode) => - setByteplusApiKey(apiKey, params.agentDir, { secretInputMode: mode }), - }); - const configWithAuth = applyAuthProfileConfig(params.config, { - profileId: "byteplus:default", - provider: "byteplus", - mode: "api_key", - }); - const configWithModel = applyPrimaryModel(configWithAuth, BYTEPLUS_DEFAULT_MODEL); - return { - config: configWithModel, - agentModelOverride: BYTEPLUS_DEFAULT_MODEL, - }; -} diff --git a/src/commands/auth-choice.apply.copilot-proxy.ts b/src/commands/auth-choice.apply.copilot-proxy.ts deleted file mode 100644 index 3906846972e..00000000000 --- a/src/commands/auth-choice.apply.copilot-proxy.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; - -export async function applyAuthChoiceCopilotProxy( - params: ApplyAuthChoiceParams, -): Promise { - return await applyAuthChoicePluginProvider(params, { - authChoice: "copilot-proxy", - pluginId: "copilot-proxy", - providerId: "copilot-proxy", - methodId: "local", - label: "Copilot Proxy", - }); -} diff --git a/src/commands/auth-choice.apply.github-copilot.ts b/src/commands/auth-choice.apply.github-copilot.ts deleted file mode 100644 index 1ef474682af..00000000000 --- a/src/commands/auth-choice.apply.github-copilot.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { toAgentModelListLike } from "../config/model-input.js"; -import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthProfileConfig } from "./onboard-auth.js"; - -export async function applyAuthChoiceGitHubCopilot( - params: ApplyAuthChoiceParams, -): Promise { - if (params.authChoice !== "github-copilot") { - return null; - } - - let nextConfig = params.config; - - await params.prompter.note( - [ - "This will open a GitHub device login to authorize Copilot.", - "Requires an active GitHub Copilot subscription.", - ].join("\n"), - "GitHub Copilot", - ); - - if (!process.stdin.isTTY) { - await params.prompter.note( - "GitHub Copilot login requires an interactive TTY.", - "GitHub Copilot", - ); - return { config: nextConfig }; - } - - try { - await githubCopilotLoginCommand({ yes: true }, params.runtime); - } catch (err) { - await params.prompter.note(`GitHub Copilot login failed: ${String(err)}`, "GitHub Copilot"); - return { config: nextConfig }; - } - - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "github-copilot:github", - provider: "github-copilot", - mode: "token", - }); - - if (params.setDefaultModel) { - const model = "github-copilot/gpt-4o"; - nextConfig = { - ...nextConfig, - agents: { - ...nextConfig.agents, - defaults: { - ...nextConfig.agents?.defaults, - model: { - ...toAgentModelListLike(nextConfig.agents?.defaults?.model), - primary: model, - }, - }, - }, - }; - await params.prompter.note(`Default model set to ${model}`, "Model configured"); - } - - return { config: nextConfig }; -} diff --git a/src/commands/auth-choice.apply.google-gemini-cli.test.ts b/src/commands/auth-choice.apply.google-gemini-cli.test.ts deleted file mode 100644 index 50a17014908..00000000000 --- a/src/commands/auth-choice.apply.google-gemini-cli.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js"; -import type { ApplyAuthChoiceParams } from "./auth-choice.apply.js"; -import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; -import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js"; - -vi.mock("./auth-choice.apply.plugin-provider.js", () => ({ - applyAuthChoicePluginProvider: vi.fn(), -})); - -function createParams( - authChoice: ApplyAuthChoiceParams["authChoice"], - overrides: Partial = {}, -): ApplyAuthChoiceParams { - return { - authChoice, - config: {}, - prompter: createWizardPrompter({}, { defaultSelect: "" }), - runtime: createExitThrowingRuntime(), - setDefaultModel: true, - ...overrides, - }; -} - -describe("applyAuthChoiceGoogleGeminiCli", () => { - const mockedApplyAuthChoicePluginProvider = vi.mocked(applyAuthChoicePluginProvider); - - beforeEach(() => { - mockedApplyAuthChoicePluginProvider.mockReset(); - }); - - it("returns null for unrelated authChoice", async () => { - const result = await applyAuthChoiceGoogleGeminiCli(createParams("openrouter-api-key")); - - expect(result).toBeNull(); - expect(mockedApplyAuthChoicePluginProvider).not.toHaveBeenCalled(); - }); - - it("shows caution and skips setup when user declines", async () => { - const confirm = vi.fn(async () => false); - const note = vi.fn(async () => {}); - const params = createParams("google-gemini-cli", { - prompter: createWizardPrompter({ confirm, note }, { defaultSelect: "" }), - }); - - const result = await applyAuthChoiceGoogleGeminiCli(params); - - expect(result).toEqual({ config: params.config }); - expect(note).toHaveBeenNthCalledWith( - 1, - expect.stringContaining("This is an unofficial integration and is not endorsed by Google."), - "Google Gemini CLI caution", - ); - expect(confirm).toHaveBeenCalledWith({ - message: "Continue with Google Gemini CLI OAuth?", - initialValue: false, - }); - expect(note).toHaveBeenNthCalledWith( - 2, - "Skipped Google Gemini CLI OAuth setup.", - "Setup skipped", - ); - expect(mockedApplyAuthChoicePluginProvider).not.toHaveBeenCalled(); - }); - - it("continues to plugin provider flow when user confirms", async () => { - const confirm = vi.fn(async () => true); - const note = vi.fn(async () => {}); - const params = createParams("google-gemini-cli", { - prompter: createWizardPrompter({ confirm, note }, { defaultSelect: "" }), - }); - const expected = { config: {} }; - mockedApplyAuthChoicePluginProvider.mockResolvedValue(expected); - - const result = await applyAuthChoiceGoogleGeminiCli(params); - - expect(result).toBe(expected); - expect(mockedApplyAuthChoicePluginProvider).toHaveBeenCalledWith(params, { - authChoice: "google-gemini-cli", - pluginId: "google", - providerId: "google-gemini-cli", - methodId: "oauth", - label: "Google Gemini CLI", - }); - }); -}); diff --git a/src/commands/auth-choice.apply.google-gemini-cli.ts b/src/commands/auth-choice.apply.google-gemini-cli.ts deleted file mode 100644 index e2aa1d02398..00000000000 --- a/src/commands/auth-choice.apply.google-gemini-cli.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; - -export async function applyAuthChoiceGoogleGeminiCli( - params: ApplyAuthChoiceParams, -): Promise { - if (params.authChoice !== "google-gemini-cli") { - return null; - } - - await params.prompter.note( - [ - "This is an unofficial integration and is not endorsed by Google.", - "Some users have reported account restrictions or suspensions after using third-party Gemini CLI and Antigravity OAuth clients.", - "Proceed only if you understand and accept this risk.", - ].join("\n"), - "Google Gemini CLI caution", - ); - - const proceed = await params.prompter.confirm({ - message: "Continue with Google Gemini CLI OAuth?", - initialValue: false, - }); - - if (!proceed) { - await params.prompter.note("Skipped Google Gemini CLI OAuth setup.", "Setup skipped"); - return { config: params.config }; - } - - return await applyAuthChoicePluginProvider(params, { - authChoice: "google-gemini-cli", - pluginId: "google", - providerId: "google-gemini-cli", - methodId: "oauth", - label: "Google Gemini CLI", - }); -} diff --git a/src/commands/auth-choice.apply.qwen-portal.ts b/src/commands/auth-choice.apply.qwen-portal.ts deleted file mode 100644 index b8c975c591f..00000000000 --- a/src/commands/auth-choice.apply.qwen-portal.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; - -export async function applyAuthChoiceQwenPortal( - params: ApplyAuthChoiceParams, -): Promise { - return await applyAuthChoicePluginProvider(params, { - authChoice: "qwen-portal", - pluginId: "qwen-portal-auth", - providerId: "qwen-portal", - methodId: "device", - label: "Qwen", - }); -} diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index b01fd65c875..798d8991199 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -3,17 +3,10 @@ import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js"; import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js"; -import { applyAuthChoiceBytePlus } from "./auth-choice.apply.byteplus.js"; -import { applyAuthChoiceCopilotProxy } from "./auth-choice.apply.copilot-proxy.js"; -import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js"; -import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js"; import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js"; import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js"; import { applyAuthChoiceLoadedPluginProvider } from "./auth-choice.apply.plugin-provider.js"; -import { applyAuthChoiceQwenPortal } from "./auth-choice.apply.qwen-portal.js"; -import { applyAuthChoiceVolcengine } from "./auth-choice.apply.volcengine.js"; -import { applyAuthChoiceXAI } from "./auth-choice.apply.xai.js"; import type { AuthChoice, OnboardOptions } from "./onboard-types.js"; export type ApplyAuthChoiceParams = { @@ -42,13 +35,6 @@ export async function applyAuthChoice( applyAuthChoiceOAuth, applyAuthChoiceApiProviders, applyAuthChoiceMiniMax, - applyAuthChoiceGitHubCopilot, - applyAuthChoiceGoogleGeminiCli, - applyAuthChoiceCopilotProxy, - applyAuthChoiceQwenPortal, - applyAuthChoiceXAI, - applyAuthChoiceVolcengine, - applyAuthChoiceBytePlus, ]; for (const handler of handlers) { diff --git a/src/commands/auth-choice.apply.volcengine-byteplus.test.ts b/src/commands/auth-choice.apply.volcengine-byteplus.test.ts deleted file mode 100644 index 0f86d06f3cd..00000000000 --- a/src/commands/auth-choice.apply.volcengine-byteplus.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { applyAuthChoiceBytePlus } from "./auth-choice.apply.byteplus.js"; -import { applyAuthChoiceVolcengine } from "./auth-choice.apply.volcengine.js"; -import { - createAuthTestLifecycle, - createExitThrowingRuntime, - createWizardPrompter, - readAuthProfilesForAgent, - setupAuthTestEnv, -} from "./test-wizard-helpers.js"; - -describe("volcengine/byteplus auth choice", () => { - const lifecycle = createAuthTestLifecycle([ - "OPENCLAW_STATE_DIR", - "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", - "VOLCANO_ENGINE_API_KEY", - "BYTEPLUS_API_KEY", - ]); - - async function setupTempState() { - const env = await setupAuthTestEnv("openclaw-volc-byte-"); - lifecycle.setStateDir(env.stateDir); - return env.agentDir; - } - - function createTestContext(defaultSelect: string, confirmResult = true, textValue = "unused") { - return { - prompter: createWizardPrompter( - { - confirm: vi.fn(async () => confirmResult), - text: vi.fn(async () => textValue), - }, - { defaultSelect }, - ), - runtime: createExitThrowingRuntime(), - }; - } - - type ProviderAuthCase = { - provider: "volcengine" | "byteplus"; - authChoice: "volcengine-api-key" | "byteplus-api-key"; - envVar: "VOLCANO_ENGINE_API_KEY" | "BYTEPLUS_API_KEY"; - envValue: string; - profileId: "volcengine:default" | "byteplus:default"; - applyAuthChoice: typeof applyAuthChoiceVolcengine | typeof applyAuthChoiceBytePlus; - }; - - async function runProviderAuthChoice( - testCase: ProviderAuthCase, - options?: { - defaultSelect?: string; - confirmResult?: boolean; - textValue?: string; - secretInputMode?: "ref"; // pragma: allowlist secret - }, - ) { - const agentDir = await setupTempState(); - process.env[testCase.envVar] = testCase.envValue; - - const { prompter, runtime } = createTestContext( - options?.defaultSelect ?? "plaintext", - options?.confirmResult ?? true, - options?.textValue ?? "unused", - ); - - const result = await testCase.applyAuthChoice({ - authChoice: testCase.authChoice, - config: {}, - prompter, - runtime, - setDefaultModel: true, - ...(options?.secretInputMode ? { opts: { secretInputMode: options.secretInputMode } } : {}), - }); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - - return { result, parsed }; - } - - const providerAuthCases: ProviderAuthCase[] = [ - { - provider: "volcengine", - authChoice: "volcengine-api-key", - envVar: "VOLCANO_ENGINE_API_KEY", - envValue: "volc-env-key", - profileId: "volcengine:default", - applyAuthChoice: applyAuthChoiceVolcengine, - }, - { - provider: "byteplus", - authChoice: "byteplus-api-key", - envVar: "BYTEPLUS_API_KEY", - envValue: "byte-env-key", - profileId: "byteplus:default", - applyAuthChoice: applyAuthChoiceBytePlus, - }, - ]; - - afterEach(async () => { - await lifecycle.cleanup(); - }); - - it.each(providerAuthCases)( - "stores $provider env key as plaintext by default", - async (testCase) => { - const { result, parsed } = await runProviderAuthChoice(testCase); - expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.[testCase.profileId]).toMatchObject({ - provider: testCase.provider, - mode: "api_key", - }); - expect(parsed.profiles?.[testCase.profileId]?.key).toBe(testCase.envValue); - expect(parsed.profiles?.[testCase.profileId]?.keyRef).toBeUndefined(); - }, - ); - - it.each(providerAuthCases)("stores $provider env key as keyRef in ref mode", async (testCase) => { - const { result, parsed } = await runProviderAuthChoice(testCase, { - defaultSelect: "ref", - }); - expect(result).not.toBeNull(); - expect(parsed.profiles?.[testCase.profileId]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: testCase.envVar }, - }); - expect(parsed.profiles?.[testCase.profileId]?.key).toBeUndefined(); - }); - - it("stores explicit volcengine key when env is not used", async () => { - const { result, parsed } = await runProviderAuthChoice(providerAuthCases[0], { - defaultSelect: "", - confirmResult: false, - textValue: "volc-manual-key", - }); - expect(result).not.toBeNull(); - expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-manual-key"); - expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined(); - }); -}); diff --git a/src/commands/auth-choice.apply.volcengine.ts b/src/commands/auth-choice.apply.volcengine.ts deleted file mode 100644 index 420dd506a26..00000000000 --- a/src/commands/auth-choice.apply.volcengine.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; -import { - ensureApiKeyFromOptionEnvOrPrompt, - normalizeSecretInputModeInput, -} from "./auth-choice.apply-helpers.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyPrimaryModel } from "./model-picker.js"; -import { applyAuthProfileConfig, setVolcengineApiKey } from "./onboard-auth.js"; - -/** Default model for Volcano Engine setup auth. */ -export const VOLCENGINE_DEFAULT_MODEL = "volcengine-plan/ark-code-latest"; - -export async function applyAuthChoiceVolcengine( - params: ApplyAuthChoiceParams, -): Promise { - if (params.authChoice !== "volcengine-api-key") { - return null; - } - - const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - await ensureApiKeyFromOptionEnvOrPrompt({ - token: params.opts?.volcengineApiKey, - tokenProvider: "volcengine", - secretInputMode: requestedSecretInputMode, - config: params.config, - expectedProviders: ["volcengine"], - provider: "volcengine", - envLabel: "VOLCANO_ENGINE_API_KEY", - promptMessage: "Enter Volcano Engine API key", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: params.prompter, - setCredential: async (apiKey, mode) => - setVolcengineApiKey(apiKey, params.agentDir, { secretInputMode: mode }), - }); - const configWithAuth = applyAuthProfileConfig(params.config, { - profileId: "volcengine:default", - provider: "volcengine", - mode: "api_key", - }); - const configWithModel = applyPrimaryModel(configWithAuth, VOLCENGINE_DEFAULT_MODEL); - return { - config: configWithModel, - agentModelOverride: VOLCENGINE_DEFAULT_MODEL, - }; -} diff --git a/src/commands/auth-choice.apply.xai.ts b/src/commands/auth-choice.apply.xai.ts deleted file mode 100644 index 68e9ac651c3..00000000000 --- a/src/commands/auth-choice.apply.xai.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; -import { - createAuthChoiceAgentModelNoter, - ensureApiKeyFromOptionEnvOrPrompt, - normalizeSecretInputModeInput, -} from "./auth-choice.apply-helpers.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; -import { - applyAuthProfileConfig, - applyXaiConfig, - applyXaiProviderConfig, - setXaiApiKey, - XAI_DEFAULT_MODEL_REF, -} from "./onboard-auth.js"; - -export async function applyAuthChoiceXAI( - params: ApplyAuthChoiceParams, -): Promise { - if (params.authChoice !== "xai-api-key") { - return null; - } - - let nextConfig = params.config; - let agentModelOverride: string | undefined; - const noteAgentModel = createAuthChoiceAgentModelNoter(params); - const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - await ensureApiKeyFromOptionEnvOrPrompt({ - token: params.opts?.xaiApiKey, - tokenProvider: "xai", - secretInputMode: requestedSecretInputMode, - config: nextConfig, - expectedProviders: ["xai"], - provider: "xai", - envLabel: "XAI_API_KEY", - promptMessage: "Enter xAI API key", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: params.prompter, - setCredential: async (apiKey, mode) => - setXaiApiKey(apiKey, params.agentDir, { secretInputMode: mode }), - }); - - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "xai:default", - provider: "xai", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: XAI_DEFAULT_MODEL_REF, - applyDefaultConfig: applyXaiConfig, - applyProviderConfig: applyXaiProviderConfig, - noteDefault: XAI_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - - return { config: nextConfig, agentModelOverride }; -} diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 35456109117..515291feb34 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -968,6 +968,33 @@ describe("applyAuthChoice", () => { it("sets default model when selecting github-copilot", async () => { await setupTempState(); + resolvePluginProviders.mockReturnValue([ + { + id: "github-copilot", + label: "GitHub Copilot", + auth: [ + { + id: "device", + label: "GitHub device login", + kind: "device_code", + run: vi.fn(async () => ({ + profiles: [ + { + profileId: "github-copilot:github", + credential: { + type: "token", + provider: "github-copilot", + token: "github-device-token", + }, + }, + ], + defaultModel: "github-copilot/gpt-4o", + })), + }, + ], + }, + ] as never); + const prompter = createPrompter({}); const runtime = createExitThrowingRuntime(); diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 025efaff67a..0ddedea92f8 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -26,6 +26,7 @@ export type { ProviderWrapStreamFnContext, OpenClawPluginService, ProviderAuthContext, + ProviderAuthDoctorHintContext, ProviderAuthMethodNonInteractiveContext, ProviderAuthResult, } from "../plugins/types.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 5fe73cd91da..6e70c8b7c19 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -109,6 +109,7 @@ export type { PluginInteractiveTelegramHandlerContext, PluginLogger, ProviderAuthContext, + ProviderAuthDoctorHintContext, ProviderAuthResult, ProviderAugmentModelCatalogContext, ProviderBuiltInModelSuppressionContext, diff --git a/src/plugins/provider-runtime.runtime.ts b/src/plugins/provider-runtime.runtime.ts index 34a46e1bdac..d4f036e1cf8 100644 --- a/src/plugins/provider-runtime.runtime.ts +++ b/src/plugins/provider-runtime.runtime.ts @@ -1,4 +1,7 @@ export { augmentModelCatalogWithProviderPlugins, + buildProviderAuthDoctorHintWithPlugin, buildProviderMissingAuthMessageWithPlugin, + formatProviderAuthProfileApiKeyWithPlugin, + refreshProviderOAuthCredentialWithPlugin, } from "./provider-runtime.js"; diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 23234be8109..266abe24556 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -14,7 +14,9 @@ vi.mock("./providers.js", () => ({ import { augmentModelCatalogWithProviderPlugins, + buildProviderAuthDoctorHintWithPlugin, buildProviderMissingAuthMessageWithPlugin, + formatProviderAuthProfileApiKeyWithPlugin, prepareProviderExtraParams, resolveProviderCacheTtlEligibility, resolveProviderBinaryThinking, @@ -28,6 +30,7 @@ import { normalizeProviderResolvedModelWithPlugin, prepareProviderDynamicModel, prepareProviderRuntimeAuth, + refreshProviderOAuthCredentialWithPlugin, resolveProviderRuntimePlugin, runProviderDynamicModel, wrapProviderStreamFn, @@ -87,6 +90,10 @@ describe("provider-runtime", () => { baseUrl: "https://runtime.example.com/v1", expiresAt: 123, })); + const refreshOAuth = vi.fn(async (cred) => ({ + ...cred, + access: "refreshed-access-token", + })); const resolveUsageAuth = vi.fn(async () => ({ token: "usage-token", accountId: "usage-account", @@ -96,34 +103,7 @@ describe("provider-runtime", () => { displayName: "Demo", windows: [{ label: "Day", usedPercent: 25 }], })); - resolvePluginProvidersMock.mockImplementation((params: unknown) => { - const scopedParams = params as { onlyPluginIds?: string[] } | undefined; - if (scopedParams?.onlyPluginIds?.includes("openai")) { - return [ - { - id: "openai", - label: "OpenAI", - auth: [], - buildMissingAuthMessage: () => - 'No API key found for provider "openai". Use openai-codex/gpt-5.4.', - suppressBuiltInModel: ({ provider, modelId }) => - provider === "azure-openai-responses" && modelId === "gpt-5.3-codex-spark" - ? { suppress: true, errorMessage: "openai-codex/gpt-5.3-codex-spark" } - : undefined, - augmentModelCatalog: () => [ - { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, - { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, - { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, - { - provider: "openai-codex", - id: "gpt-5.3-codex-spark", - name: "gpt-5.3-codex-spark", - }, - ], - }, - ]; - } - + resolvePluginProvidersMock.mockImplementation((_params: unknown) => { return [ { id: "demo", @@ -143,6 +123,11 @@ describe("provider-runtime", () => { ...model, api: "openai-codex-responses", }), + formatApiKey: (cred) => + cred.type === "oauth" ? JSON.stringify({ token: cred.access }) : "", + refreshOAuth, + buildAuthDoctorHint: ({ provider, profileId }) => + provider === "demo" ? `Repair ${profileId}` : undefined, prepareRuntimeAuth, resolveUsageAuth, fetchUsageSnapshot, @@ -152,6 +137,27 @@ describe("provider-runtime", () => { resolveDefaultThinkingLevel: ({ reasoning }) => (reasoning ? "low" : "off"), isModernModelRef: ({ modelId }) => modelId.startsWith("gpt-5"), }, + { + id: "openai", + label: "OpenAI", + auth: [], + buildMissingAuthMessage: () => + 'No API key found for provider "openai". Use openai-codex/gpt-5.4.', + suppressBuiltInModel: ({ provider, modelId }) => + provider === "azure-openai-responses" && modelId === "gpt-5.3-codex-spark" + ? { suppress: true, errorMessage: "openai-codex/gpt-5.3-codex-spark" } + : undefined, + augmentModelCatalog: () => [ + { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, + { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, + { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, + { + provider: "openai-codex", + id: "gpt-5.3-codex-spark", + name: "gpt-5.3-codex-spark", + }, + ], + }, ]; }); @@ -241,6 +247,45 @@ describe("provider-runtime", () => { expiresAt: 123, }); + expect( + formatProviderAuthProfileApiKeyWithPlugin({ + provider: "demo", + context: { + type: "oauth", + provider: "demo", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }), + ).toBe('{"token":"oauth-access"}'); + + await expect( + refreshProviderOAuthCredentialWithPlugin({ + provider: "demo", + context: { + type: "oauth", + provider: "demo", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }), + ).resolves.toMatchObject({ + access: "refreshed-access-token", + }); + + await expect( + buildProviderAuthDoctorHintWithPlugin({ + provider: "demo", + context: { + provider: "demo", + profileId: "demo:default", + store: { version: 1, profiles: {} }, + }, + }), + ).resolves.toBe("Repair demo:default"); + await expect( resolveProviderUsageAuthWithPlugin({ provider: "demo", @@ -376,12 +421,8 @@ describe("provider-runtime", () => { }, ]); - expect(resolvePluginProvidersMock).toHaveBeenCalledWith( - expect.objectContaining({ - onlyPluginIds: ["openai"], - }), - ); expect(prepareDynamicModel).toHaveBeenCalledTimes(1); + expect(refreshOAuth).toHaveBeenCalledTimes(1); expect(prepareRuntimeAuth).toHaveBeenCalledTimes(1); expect(resolveUsageAuth).toHaveBeenCalledTimes(1); expect(fetchUsageSnapshot).toHaveBeenCalledTimes(1); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 41c0a70ec4d..189b5ccef0c 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -1,7 +1,9 @@ +import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-profiles/types.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js"; import type { + ProviderAuthDoctorHintContext, ProviderAugmentModelCatalogContext, ProviderBuildMissingAuthMessageContext, ProviderBuiltInModelSuppressionContext, @@ -46,19 +48,6 @@ function resolveProviderPluginsForHooks(params: { }); } -const GLOBAL_PROVIDER_HOOK_PLUGIN_IDS = ["openai"] as const; - -function resolveGlobalProviderHookPlugins(params: { - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; -}): ProviderPlugin[] { - return resolveProviderPluginsForHooks({ - ...params, - onlyPluginIds: [...GLOBAL_PROVIDER_HOOK_PLUGIN_IDS], - }); -} - export function resolveProviderRuntimePlugin(params: { provider: string; config?: OpenClawConfig; @@ -174,6 +163,36 @@ export async function resolveProviderUsageSnapshotWithPlugin(params: { return await resolveProviderRuntimePlugin(params)?.fetchUsageSnapshot?.(params.context); } +export function formatProviderAuthProfileApiKeyWithPlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: AuthProfileCredential; +}) { + return resolveProviderRuntimePlugin(params)?.formatApiKey?.(params.context); +} + +export async function refreshProviderOAuthCredentialWithPlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: OAuthCredential; +}) { + return await resolveProviderRuntimePlugin(params)?.refreshOAuth?.(params.context); +} + +export async function buildProviderAuthDoctorHintWithPlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderAuthDoctorHintContext; +}) { + return await resolveProviderRuntimePlugin(params)?.buildAuthDoctorHint?.(params.context); +} + export function resolveProviderCacheTtlEligibility(params: { provider: string; config?: OpenClawConfig; @@ -231,10 +250,9 @@ export function buildProviderMissingAuthMessageWithPlugin(params: { env?: NodeJS.ProcessEnv; context: ProviderBuildMissingAuthMessageContext; }) { - const plugin = resolveGlobalProviderHookPlugins(params).find((providerPlugin) => - matchesProviderId(providerPlugin, params.provider), + return ( + resolveProviderRuntimePlugin(params)?.buildMissingAuthMessage?.(params.context) ?? undefined ); - return plugin?.buildMissingAuthMessage?.(params.context) ?? undefined; } export function resolveProviderBuiltInModelSuppression(params: { @@ -243,7 +261,7 @@ export function resolveProviderBuiltInModelSuppression(params: { env?: NodeJS.ProcessEnv; context: ProviderBuiltInModelSuppressionContext; }) { - for (const plugin of resolveGlobalProviderHookPlugins(params)) { + for (const plugin of resolveProviderPluginsForHooks(params)) { const result = plugin.suppressBuiltInModel?.(params.context); if (result?.suppress) { return result; @@ -259,7 +277,7 @@ export async function augmentModelCatalogWithProviderPlugins(params: { context: ProviderAugmentModelCatalogContext; }) { const supplemental = [] as ProviderAugmentModelCatalogContext["entries"]; - for (const plugin of resolveGlobalProviderHookPlugins(params)) { + for (const plugin of resolveProviderPluginsForHooks(params)) { const next = await plugin.augmentModelCatalog?.(params.context); if (!next || next.length === 0) { continue; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index ac40aa9f912..bea63007fb2 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -9,6 +9,7 @@ import type { ApiKeyCredential, AuthProfileCredential, OAuthCredential, + AuthProfileStore, } from "../agents/auth-profiles/types.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import type { ProviderCapabilities } from "../agents/provider-capabilities.js"; @@ -367,6 +368,20 @@ export type ProviderFetchUsageSnapshotContext = { fetchFn: typeof fetch; }; +/** + * Provider-owned auth-doctor hint input. + * + * Called when OAuth refresh fails and OpenClaw wants a provider-specific repair + * hint to append to the generic re-auth message. Use this for legacy profile-id + * migrations or other provider-owned auth-store cleanup guidance. + */ +export type ProviderAuthDoctorHintContext = { + config?: OpenClawConfig; + store: AuthProfileStore; + provider: string; + profileId?: string; +}; + /** * Provider-owned extra-param normalization before OpenClaw builds its generic * stream option wrapper. @@ -732,8 +747,34 @@ export type ProviderPlugin = { */ isModernModelRef?: (ctx: ProviderModernModelPolicyContext) => boolean | undefined; wizard?: ProviderPluginWizard; + /** + * Provider-owned auth-profile API-key formatter. + * + * OpenClaw uses this when a stored auth profile is already valid and needs to + * be converted into the runtime `apiKey` string expected by the provider. Use + * this for providers whose auth profile stores extra metadata alongside the + * bearer token (for example Gemini CLI's `{ token, projectId }` payload). + */ formatApiKey?: (cred: AuthProfileCredential) => string; + /** + * Provider-owned OAuth refresh. + * + * OpenClaw calls this before falling back to the shared `pi-ai` OAuth + * refreshers. Use it when the provider has a custom refresh endpoint, or when + * the provider needs custom refresh-failure behavior that should stay out of + * core auth-profile code. + */ refreshOAuth?: (cred: OAuthCredential) => Promise; + /** + * Provider-owned auth-doctor hint. + * + * Return a multiline repair hint when OAuth refresh fails and the provider + * wants to steer users toward a specific auth-profile migration or recovery + * path. Return nothing to keep OpenClaw's generic error text. + */ + buildAuthDoctorHint?: ( + ctx: ProviderAuthDoctorHintContext, + ) => string | Promise | null | undefined; onModelSelected?: (ctx: ProviderModelSelectedContext) => Promise; }; From 304703f1654a142acf69747f4d91eb3d7220c96b Mon Sep 17 00:00:00 2001 From: Joey Krug Date: Sun, 15 Mar 2026 21:04:39 -0400 Subject: [PATCH 004/133] fix: resume orphaned subagent sessions after SIGUSR1 reload Closes #47711 After a SIGUSR1 gateway reload aborts in-flight subagent LLM calls, the gateway now scans for orphaned sessions and sends a synthetic resume message to restart their work. Also makes the deferral timeout configurable via gateway.reload.deferralTimeoutMs (default: 5 minutes, up from 90s). --- src/agents/subagent-orphan-recovery.test.ts | 316 ++++++++++++++++++++ src/agents/subagent-orphan-recovery.ts | 185 ++++++++++++ src/agents/subagent-registry.ts | 8 + src/config/schema.help.ts | 2 + src/config/types.gateway.ts | 7 + src/config/zod-schema.ts | 1 + src/gateway/server-reload-handlers.ts | 1 + src/infra/restart.deferral-timeout.test.ts | 119 ++++++++ src/infra/restart.ts | 5 +- 9 files changed, 642 insertions(+), 2 deletions(-) create mode 100644 src/agents/subagent-orphan-recovery.test.ts create mode 100644 src/agents/subagent-orphan-recovery.ts create mode 100644 src/infra/restart.deferral-timeout.test.ts diff --git a/src/agents/subagent-orphan-recovery.test.ts b/src/agents/subagent-orphan-recovery.test.ts new file mode 100644 index 00000000000..7792140e3db --- /dev/null +++ b/src/agents/subagent-orphan-recovery.test.ts @@ -0,0 +1,316 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; + +// Mock dependencies before importing the module under test +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(() => ({ + session: { store: undefined }, + })), +})); + +vi.mock("../config/sessions.js", () => ({ + loadSessionStore: vi.fn(() => ({})), + resolveAgentIdFromSessionKey: vi.fn(() => "main"), + resolveStorePath: vi.fn(() => "/tmp/test-sessions.json"), + updateSessionStore: vi.fn(async () => {}), +})); + +vi.mock("../gateway/call.js", () => ({ + callGateway: vi.fn(async () => ({ runId: "test-run-id" })), +})); + +function createTestRunRecord(overrides: Partial = {}): SubagentRunRecord { + return { + runId: "run-1", + childSessionKey: "agent:main:subagent:test-session-1", + requesterSessionKey: "agent:main:signal:direct:+1234567890", + requesterDisplayKey: "main", + task: "Test task: implement feature X", + cleanup: "delete", + createdAt: Date.now() - 60_000, + startedAt: Date.now() - 55_000, + ...overrides, + }; +} + +describe("subagent-orphan-recovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("recovers orphaned sessions with abortedLastRun=true", async () => { + const sessions = await import("../config/sessions.js"); + const gateway = await import("../gateway/call.js"); + + const sessionEntry = { + sessionId: "session-abc", + updatedAt: Date.now(), + abortedLastRun: true, + }; + + vi.mocked(sessions.loadSessionStore).mockReturnValue({ + "agent:main:subagent:test-session-1": sessionEntry, + }); + + const activeRuns = new Map(); + activeRuns.set("run-1", createTestRunRecord()); + + const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); + + const result = await recoverOrphanedSubagentSessions({ + getActiveRuns: () => activeRuns, + }); + + expect(result.recovered).toBe(1); + expect(result.failed).toBe(0); + expect(result.skipped).toBe(0); + + // Should have called callGateway to resume the session + expect(gateway.callGateway).toHaveBeenCalledOnce(); + const callArgs = vi.mocked(gateway.callGateway).mock.calls[0]; + const opts = callArgs[0]; + expect(opts.method).toBe("agent"); + const params = opts.params as Record; + expect(params.sessionKey).toBe("agent:main:subagent:test-session-1"); + expect(params.message).toContain("gateway reload"); + expect(params.message).toContain("Test task: implement feature X"); + }); + + it("skips sessions that are not aborted", async () => { + const sessions = await import("../config/sessions.js"); + const gateway = await import("../gateway/call.js"); + + vi.mocked(sessions.loadSessionStore).mockReturnValue({ + "agent:main:subagent:test-session-1": { + sessionId: "session-abc", + updatedAt: Date.now(), + abortedLastRun: false, + }, + }); + + const activeRuns = new Map(); + activeRuns.set("run-1", createTestRunRecord()); + + const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); + + const result = await recoverOrphanedSubagentSessions({ + getActiveRuns: () => activeRuns, + }); + + expect(result.recovered).toBe(0); + expect(result.skipped).toBe(1); + expect(gateway.callGateway).not.toHaveBeenCalled(); + }); + + it("skips runs that have already ended", async () => { + const gateway = await import("../gateway/call.js"); + + const activeRuns = new Map(); + activeRuns.set( + "run-1", + createTestRunRecord({ + endedAt: Date.now() - 1000, + }), + ); + + const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); + + const result = await recoverOrphanedSubagentSessions({ + getActiveRuns: () => activeRuns, + }); + + expect(result.recovered).toBe(0); + expect(gateway.callGateway).not.toHaveBeenCalled(); + }); + + it("handles multiple orphaned sessions", async () => { + const sessions = await import("../config/sessions.js"); + const gateway = await import("../gateway/call.js"); + + vi.mocked(sessions.loadSessionStore).mockReturnValue({ + "agent:main:subagent:session-a": { + sessionId: "id-a", + updatedAt: Date.now(), + abortedLastRun: true, + }, + "agent:main:subagent:session-b": { + sessionId: "id-b", + updatedAt: Date.now(), + abortedLastRun: true, + }, + "agent:main:subagent:session-c": { + sessionId: "id-c", + updatedAt: Date.now(), + abortedLastRun: false, + }, + }); + + const activeRuns = new Map(); + activeRuns.set( + "run-a", + createTestRunRecord({ + runId: "run-a", + childSessionKey: "agent:main:subagent:session-a", + task: "Task A", + }), + ); + activeRuns.set( + "run-b", + createTestRunRecord({ + runId: "run-b", + childSessionKey: "agent:main:subagent:session-b", + task: "Task B", + }), + ); + activeRuns.set( + "run-c", + createTestRunRecord({ + runId: "run-c", + childSessionKey: "agent:main:subagent:session-c", + task: "Task C", + }), + ); + + const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); + + const result = await recoverOrphanedSubagentSessions({ + getActiveRuns: () => activeRuns, + }); + + expect(result.recovered).toBe(2); + expect(result.skipped).toBe(1); + expect(gateway.callGateway).toHaveBeenCalledTimes(2); + }); + + it("handles callGateway failure gracefully", async () => { + const sessions = await import("../config/sessions.js"); + const gateway = await import("../gateway/call.js"); + + vi.mocked(sessions.loadSessionStore).mockReturnValue({ + "agent:main:subagent:test-session-1": { + sessionId: "session-abc", + updatedAt: Date.now(), + abortedLastRun: true, + }, + }); + + vi.mocked(gateway.callGateway).mockRejectedValue(new Error("gateway unavailable")); + + const activeRuns = new Map(); + activeRuns.set("run-1", createTestRunRecord()); + + const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); + + const result = await recoverOrphanedSubagentSessions({ + getActiveRuns: () => activeRuns, + }); + + expect(result.recovered).toBe(0); + expect(result.failed).toBe(1); + }); + + it("returns empty results when no active runs exist", async () => { + const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); + + const result = await recoverOrphanedSubagentSessions({ + getActiveRuns: () => new Map(), + }); + + expect(result.recovered).toBe(0); + expect(result.failed).toBe(0); + expect(result.skipped).toBe(0); + }); + + it("skips sessions with missing session entry in store", async () => { + const sessions = await import("../config/sessions.js"); + const gateway = await import("../gateway/call.js"); + + // Store has no matching entry + vi.mocked(sessions.loadSessionStore).mockReturnValue({}); + + const activeRuns = new Map(); + activeRuns.set("run-1", createTestRunRecord()); + + const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); + + const result = await recoverOrphanedSubagentSessions({ + getActiveRuns: () => activeRuns, + }); + + expect(result.recovered).toBe(0); + expect(result.skipped).toBe(1); + expect(gateway.callGateway).not.toHaveBeenCalled(); + }); + + it("clears abortedLastRun flag before resuming", async () => { + const sessions = await import("../config/sessions.js"); + + vi.mocked(sessions.loadSessionStore).mockReturnValue({ + "agent:main:subagent:test-session-1": { + sessionId: "session-abc", + updatedAt: Date.now(), + abortedLastRun: true, + }, + }); + + const activeRuns = new Map(); + activeRuns.set("run-1", createTestRunRecord()); + + const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); + + await recoverOrphanedSubagentSessions({ + getActiveRuns: () => activeRuns, + }); + + // updateSessionStore should have been called to clear the flag + expect(sessions.updateSessionStore).toHaveBeenCalledOnce(); + const calls = vi.mocked(sessions.updateSessionStore).mock.calls; + const [storePath, updater] = calls[0]; + expect(storePath).toBe("/tmp/test-sessions.json"); + + // Simulate the updater to verify it clears abortedLastRun + const mockStore: Record = { + "agent:main:subagent:test-session-1": { + abortedLastRun: true, + updatedAt: 0, + }, + }; + (updater as (store: Record) => void)(mockStore); + expect(mockStore["agent:main:subagent:test-session-1"]?.abortedLastRun).toBe(false); + }); + + it("truncates long task descriptions in resume message", async () => { + const sessions = await import("../config/sessions.js"); + const gateway = await import("../gateway/call.js"); + + vi.mocked(sessions.loadSessionStore).mockReturnValue({ + "agent:main:subagent:test-session-1": { + sessionId: "session-abc", + updatedAt: Date.now(), + abortedLastRun: true, + }, + }); + + const longTask = "x".repeat(5000); + const activeRuns = new Map(); + activeRuns.set("run-1", createTestRunRecord({ task: longTask })); + + const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); + + await recoverOrphanedSubagentSessions({ + getActiveRuns: () => activeRuns, + }); + + const callArgs = vi.mocked(gateway.callGateway).mock.calls[0]; + const opts = callArgs[0]; + const params = opts.params as Record; + const message = params.message as string; + // Message should contain truncated task (2000 chars + "...") + expect(message.length).toBeLessThan(5000); + expect(message).toContain("..."); + }); +}); diff --git a/src/agents/subagent-orphan-recovery.ts b/src/agents/subagent-orphan-recovery.ts new file mode 100644 index 00000000000..fc30af26038 --- /dev/null +++ b/src/agents/subagent-orphan-recovery.ts @@ -0,0 +1,185 @@ +/** + * Post-restart orphan recovery for subagent sessions. + * + * After a SIGUSR1 gateway reload aborts in-flight subagent LLM calls, + * this module scans for orphaned sessions (those with `abortedLastRun: true` + * that are still tracked as active in the subagent registry) and sends a + * synthetic resume message to restart their work. + * + * @see https://github.com/openclaw/openclaw/issues/47711 + */ + +import crypto from "node:crypto"; +import { loadConfig } from "../config/config.js"; +import { + loadSessionStore, + resolveAgentIdFromSessionKey, + resolveStorePath, + updateSessionStore, + type SessionEntry, +} from "../config/sessions.js"; +import { callGateway } from "../gateway/call.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; + +const log = createSubsystemLogger("subagent-orphan-recovery"); + +/** Delay before attempting recovery to let the gateway finish bootstrapping. */ +const DEFAULT_RECOVERY_DELAY_MS = 5_000; + +/** + * Build the resume message for an orphaned subagent. + */ +function buildResumeMessage(task: string): string { + const maxTaskLen = 2000; + const truncatedTask = task.length > maxTaskLen ? `${task.slice(0, maxTaskLen)}...` : task; + + return ( + `[System] Your previous turn was interrupted by a gateway reload. ` + + `Your task was:\n\n${truncatedTask}\n\nPlease continue where you left off.` + ); +} + +/** + * Send a resume message to an orphaned subagent session via the gateway agent method. + */ +async function resumeOrphanedSession(params: { + sessionKey: string; + task: string; +}): Promise { + const resumeMessage = buildResumeMessage(params.task); + + try { + await callGateway<{ runId: string }>({ + method: "agent", + params: { + message: resumeMessage, + sessionKey: params.sessionKey, + idempotencyKey: crypto.randomUUID(), + deliver: false, + lane: "subagent", + }, + timeoutMs: 10_000, + }); + log.info(`resumed orphaned session: ${params.sessionKey}`); + return true; + } catch (err) { + log.warn(`failed to resume orphaned session ${params.sessionKey}: ${String(err)}`); + return false; + } +} + +/** + * Scan for and resume orphaned subagent sessions after a gateway restart. + * + * An orphaned session is one where: + * 1. It has an active (not ended) entry in the subagent run registry + * 2. Its session store entry has `abortedLastRun: true` + * + * For each orphaned session found, we: + * 1. Clear the `abortedLastRun` flag + * 2. Send a synthetic resume message to trigger a new LLM turn + */ +export async function recoverOrphanedSubagentSessions(params: { + getActiveRuns: () => Map; +}): Promise<{ recovered: number; failed: number; skipped: number }> { + const result = { recovered: 0, failed: 0, skipped: 0 }; + + try { + const activeRuns = params.getActiveRuns(); + if (activeRuns.size === 0) { + return result; + } + + const cfg = loadConfig(); + const storeCache = new Map>(); + + for (const [runId, runRecord] of activeRuns.entries()) { + // Only consider runs that haven't ended yet + if (typeof runRecord.endedAt === "number" && runRecord.endedAt > 0) { + continue; + } + + const childSessionKey = runRecord.childSessionKey?.trim(); + if (!childSessionKey) { + continue; + } + + try { + const agentId = resolveAgentIdFromSessionKey(childSessionKey); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + + let store = storeCache.get(storePath); + if (!store) { + store = loadSessionStore(storePath); + storeCache.set(storePath, store); + } + + const entry = store[childSessionKey]; + if (!entry) { + result.skipped++; + continue; + } + + // Check if this session was aborted by the restart + if (!entry.abortedLastRun) { + result.skipped++; + continue; + } + + log.info(`found orphaned subagent session: ${childSessionKey} (run=${runId})`); + + // Clear the aborted flag before resuming + await updateSessionStore(storePath, (currentStore) => { + const current = currentStore[childSessionKey]; + if (current) { + current.abortedLastRun = false; + current.updatedAt = Date.now(); + currentStore[childSessionKey] = current; + } + }); + + // Resume the session with the original task context + const resumed = await resumeOrphanedSession({ + sessionKey: childSessionKey, + task: runRecord.task, + }); + + if (resumed) { + result.recovered++; + } else { + result.failed++; + } + } catch (err) { + log.warn(`error processing orphaned session ${childSessionKey}: ${String(err)}`); + result.failed++; + } + } + } catch (err) { + log.warn(`orphan recovery scan failed: ${String(err)}`); + } + + if (result.recovered > 0 || result.failed > 0) { + log.info( + `orphan recovery complete: recovered=${result.recovered} failed=${result.failed} skipped=${result.skipped}`, + ); + } + + return result; +} + +/** + * Schedule orphan recovery after a delay. + * The delay gives the gateway time to fully bootstrap after restart. + */ +export function scheduleOrphanRecovery(params: { + getActiveRuns: () => Map; + delayMs?: number; +}): void { + const delay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS; + setTimeout(() => { + void recoverOrphanedSubagentSessions(params).catch((err) => { + log.warn(`scheduled orphan recovery failed: ${String(err)}`); + }); + }, delay).unref?.(); +} diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index d9c593c3e84..68fd499cbee 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -30,6 +30,7 @@ import { SUBAGENT_ENDED_REASON_KILLED, type SubagentLifecycleEndedReason, } from "./subagent-lifecycle-events.js"; +import { scheduleOrphanRecovery } from "./subagent-orphan-recovery.js"; import { resolveCleanupCompletionReason, resolveDeferredCleanupDecision, @@ -684,6 +685,13 @@ function restoreSubagentRunsOnce() { for (const runId of subagentRuns.keys()) { resumeSubagentRun(runId); } + + // Schedule orphan recovery for subagent sessions that were aborted + // by a SIGUSR1 reload. This runs after a short delay to let the + // gateway fully bootstrap first. (#47711) + scheduleOrphanRecovery({ + getActiveRuns: () => subagentRuns, + }); } catch { // ignore restore failures } diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index d591ba53533..8a71c0e9035 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -427,6 +427,8 @@ export const FIELD_HELP: Record = { "gateway.reload.mode": 'Controls how config edits are applied: "off" ignores live edits, "restart" always restarts, "hot" applies in-process, and "hybrid" tries hot then restarts if required. Keep "hybrid" for safest routine updates.', "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", + "gateway.reload.deferralTimeoutMs": + "Maximum time (ms) to wait for in-flight operations to complete before forcing a SIGUSR1 restart. Default: 300000 (5 minutes). Lower values risk aborting active subagent LLM calls.", "gateway.nodes.browser.mode": 'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).', "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 88a5350ab1d..385ece27aad 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -211,6 +211,13 @@ export type GatewayReloadConfig = { mode?: GatewayReloadMode; /** Debounce window for config reloads (ms). Default: 300. */ debounceMs?: number; + /** + * Maximum time (ms) to wait for in-flight operations to complete before + * forcing a SIGUSR1 restart. Default: 300000 (5 minutes). + * Lower values risk aborting active subagent LLM calls. + * @see https://github.com/openclaw/openclaw/issues/47711 + */ + deferralTimeoutMs?: number; }; export type GatewayHttpChatCompletionsConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 20b8b232157..7c9b510080f 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -728,6 +728,7 @@ export const OpenClawSchema = z ]) .optional(), debounceMs: z.number().int().min(0).optional(), + deferralTimeoutMs: z.number().int().min(0).optional(), }) .strict() .optional(), diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 008f0977d37..b92d71889bc 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -219,6 +219,7 @@ export function createGatewayReloadHandlers(params: { deferGatewayRestartUntilIdle({ getPendingCount: () => getActiveCounts().totalActive, + maxWaitMs: nextConfig.gateway?.reload?.deferralTimeoutMs, hooks: { onReady: () => { restartPending = false; diff --git a/src/infra/restart.deferral-timeout.test.ts b/src/infra/restart.deferral-timeout.test.ts new file mode 100644 index 00000000000..167fe95ccdc --- /dev/null +++ b/src/infra/restart.deferral-timeout.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { __testing, deferGatewayRestartUntilIdle, type RestartDeferralHooks } from "./restart.js"; + +describe("deferGatewayRestartUntilIdle timeout", () => { + beforeEach(() => { + vi.useFakeTimers(); + __testing.resetSigusr1State(); + // Add a listener so emitGatewayRestart uses process.emit instead of process.kill + process.on("SIGUSR1", () => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + __testing.resetSigusr1State(); + process.removeAllListeners("SIGUSR1"); + }); + + it("uses default 5-minute timeout when maxWaitMs is not specified", () => { + const hooks: RestartDeferralHooks = { + onTimeout: vi.fn(), + onReady: vi.fn(), + }; + + // Always return 1 pending item to prevent draining + deferGatewayRestartUntilIdle({ + getPendingCount: () => 1, + hooks, + }); + + // Advance to just before 5 minutes — should NOT have timed out yet + vi.advanceTimersByTime(299_999); + expect(hooks.onTimeout).not.toHaveBeenCalled(); + + // Advance past 5 minutes — should time out + vi.advanceTimersByTime(1); + expect(hooks.onTimeout).toHaveBeenCalledOnce(); + expect(hooks.onReady).not.toHaveBeenCalled(); + }); + + it("respects custom maxWaitMs configuration", () => { + const hooks: RestartDeferralHooks = { + onTimeout: vi.fn(), + onReady: vi.fn(), + }; + + const customTimeoutMs = 120_000; // 2 minutes + + deferGatewayRestartUntilIdle({ + getPendingCount: () => 1, + maxWaitMs: customTimeoutMs, + hooks, + }); + + // Advance to just before 2 minutes + vi.advanceTimersByTime(119_999); + expect(hooks.onTimeout).not.toHaveBeenCalled(); + + // Advance past 2 minutes + vi.advanceTimersByTime(1); + expect(hooks.onTimeout).toHaveBeenCalledOnce(); + }); + + it("calls onReady and does not timeout when pending count drops to 0", () => { + const hooks: RestartDeferralHooks = { + onTimeout: vi.fn(), + onReady: vi.fn(), + }; + + let pending = 3; + + deferGatewayRestartUntilIdle({ + getPendingCount: () => pending, + hooks, + }); + + // Advance a few poll intervals, then drain + vi.advanceTimersByTime(1000); + expect(hooks.onReady).not.toHaveBeenCalled(); + + pending = 0; + vi.advanceTimersByTime(500); // Next poll interval + expect(hooks.onReady).toHaveBeenCalledOnce(); + expect(hooks.onTimeout).not.toHaveBeenCalled(); + }); + + it("immediately restarts when pending count is 0", () => { + const hooks: RestartDeferralHooks = { + onReady: vi.fn(), + onTimeout: vi.fn(), + }; + + deferGatewayRestartUntilIdle({ + getPendingCount: () => 0, + hooks, + }); + + // onReady should be called synchronously + expect(hooks.onReady).toHaveBeenCalledOnce(); + expect(hooks.onTimeout).not.toHaveBeenCalled(); + }); + + it("handles getPendingCount error by restarting immediately", () => { + const hooks: RestartDeferralHooks = { + onCheckError: vi.fn(), + onReady: vi.fn(), + }; + + deferGatewayRestartUntilIdle({ + getPendingCount: () => { + throw new Error("store corrupted"); + }, + hooks, + }); + + expect(hooks.onCheckError).toHaveBeenCalledOnce(); + expect(hooks.onReady).not.toHaveBeenCalled(); + }); +}); diff --git a/src/infra/restart.ts b/src/infra/restart.ts index 3e0379f25f2..1238b4954d0 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -19,8 +19,9 @@ export type RestartAttempt = { const SPAWN_TIMEOUT_MS = 2000; const SIGUSR1_AUTH_GRACE_MS = 5000; const DEFAULT_DEFERRAL_POLL_MS = 500; -// Cover slow in-flight embedded compaction work before forcing restart. -const DEFAULT_DEFERRAL_MAX_WAIT_MS = 90_000; +// Default to 5 minutes to avoid aborting in-flight subagent LLM calls. +// Configurable via gateway.reload.deferralTimeoutMs. +const DEFAULT_DEFERRAL_MAX_WAIT_MS = 300_000; const RESTART_COOLDOWN_MS = 30_000; const restartLog = createSubsystemLogger("restart"); From 0311ff05d77ad6a5f10cb0c49f8800b8b4a80e81 Mon Sep 17 00:00:00 2001 From: Joey Krug Date: Sun, 15 Mar 2026 21:16:24 -0400 Subject: [PATCH 005/133] fix: address Greptile review feedback - Remove unrelated pnpm-lock.yaml changes - Move abortedLastRun flag clearing to AFTER successful resume (prevents permanent session loss on transient gateway failures) - Use dynamic import for orphan recovery module to avoid startup memory overhead - Add test assertion that flag is preserved on resume failure --- src/agents/subagent-orphan-recovery.test.ts | 14 ++++++++--- src/agents/subagent-orphan-recovery.ts | 28 +++++++++++++-------- src/agents/subagent-registry.ts | 15 +++++++---- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/agents/subagent-orphan-recovery.test.ts b/src/agents/subagent-orphan-recovery.test.ts index 7792140e3db..351e1079dc7 100644 --- a/src/agents/subagent-orphan-recovery.test.ts +++ b/src/agents/subagent-orphan-recovery.test.ts @@ -186,7 +186,7 @@ describe("subagent-orphan-recovery", () => { expect(gateway.callGateway).toHaveBeenCalledTimes(2); }); - it("handles callGateway failure gracefully", async () => { + it("handles callGateway failure gracefully and preserves abortedLastRun flag", async () => { const sessions = await import("../config/sessions.js"); const gateway = await import("../gateway/call.js"); @@ -211,6 +211,10 @@ describe("subagent-orphan-recovery", () => { expect(result.recovered).toBe(0); expect(result.failed).toBe(1); + + // abortedLastRun flag should NOT be cleared on failure, + // so the next restart can retry the recovery + expect(sessions.updateSessionStore).not.toHaveBeenCalled(); }); it("returns empty results when no active runs exist", async () => { @@ -246,8 +250,12 @@ describe("subagent-orphan-recovery", () => { expect(gateway.callGateway).not.toHaveBeenCalled(); }); - it("clears abortedLastRun flag before resuming", async () => { + it("clears abortedLastRun flag after successful resume", async () => { const sessions = await import("../config/sessions.js"); + const gateway = await import("../gateway/call.js"); + + // Ensure callGateway succeeds for this test + vi.mocked(gateway.callGateway).mockResolvedValue({ runId: "resumed-run" } as never); vi.mocked(sessions.loadSessionStore).mockReturnValue({ "agent:main:subagent:test-session-1": { @@ -266,7 +274,7 @@ describe("subagent-orphan-recovery", () => { getActiveRuns: () => activeRuns, }); - // updateSessionStore should have been called to clear the flag + // updateSessionStore should have been called AFTER successful resume to clear the flag expect(sessions.updateSessionStore).toHaveBeenCalledOnce(); const calls = vi.mocked(sessions.updateSessionStore).mock.calls; const [storePath, updater] = calls[0]; diff --git a/src/agents/subagent-orphan-recovery.ts b/src/agents/subagent-orphan-recovery.ts index fc30af26038..f36096d95b2 100644 --- a/src/agents/subagent-orphan-recovery.ts +++ b/src/agents/subagent-orphan-recovery.ts @@ -129,25 +129,31 @@ export async function recoverOrphanedSubagentSessions(params: { log.info(`found orphaned subagent session: ${childSessionKey} (run=${runId})`); - // Clear the aborted flag before resuming - await updateSessionStore(storePath, (currentStore) => { - const current = currentStore[childSessionKey]; - if (current) { - current.abortedLastRun = false; - current.updatedAt = Date.now(); - currentStore[childSessionKey] = current; - } - }); - - // Resume the session with the original task context + // Resume the session with the original task context. + // We intentionally do NOT clear abortedLastRun before attempting + // the resume — if callGateway fails (e.g. gateway still booting), + // the flag stays true so the next restart can retry. const resumed = await resumeOrphanedSession({ sessionKey: childSessionKey, task: runRecord.task, }); if (resumed) { + // Only clear the aborted flag after confirmed successful resume. + await updateSessionStore(storePath, (currentStore) => { + const current = currentStore[childSessionKey]; + if (current) { + current.abortedLastRun = false; + current.updatedAt = Date.now(); + currentStore[childSessionKey] = current; + } + }); result.recovered++; } else { + // Flag stays as abortedLastRun=true so next restart can retry + log.warn( + `resume failed for ${childSessionKey}; abortedLastRun flag preserved for retry on next restart`, + ); result.failed++; } } catch (err) { diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 68fd499cbee..c1cab60dd82 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -30,7 +30,6 @@ import { SUBAGENT_ENDED_REASON_KILLED, type SubagentLifecycleEndedReason, } from "./subagent-lifecycle-events.js"; -import { scheduleOrphanRecovery } from "./subagent-orphan-recovery.js"; import { resolveCleanupCompletionReason, resolveDeferredCleanupDecision, @@ -688,10 +687,16 @@ function restoreSubagentRunsOnce() { // Schedule orphan recovery for subagent sessions that were aborted // by a SIGUSR1 reload. This runs after a short delay to let the - // gateway fully bootstrap first. (#47711) - scheduleOrphanRecovery({ - getActiveRuns: () => subagentRuns, - }); + // gateway fully bootstrap first. Dynamic import to avoid increasing + // startup memory footprint. (#47711) + void import("./subagent-orphan-recovery.js").then( + ({ scheduleOrphanRecovery }) => { + scheduleOrphanRecovery({ getActiveRuns: () => subagentRuns }); + }, + () => { + // Ignore import failures — orphan recovery is best-effort. + }, + ); } catch { // ignore restore failures } From 44304ba24af8d9fffb581308fbb6bc9a3cf2b947 Mon Sep 17 00:00:00 2001 From: Joey Krug Date: Sun, 15 Mar 2026 21:25:21 -0400 Subject: [PATCH 006/133] fix: add retry with exponential backoff for orphan recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex review feedback — if recovery fails (e.g. gateway still booting), retries up to 3 times with exponential backoff (5s → 10s → 20s) before giving up. --- src/agents/subagent-orphan-recovery.ts | 47 ++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/agents/subagent-orphan-recovery.ts b/src/agents/subagent-orphan-recovery.ts index f36096d95b2..02dfa1528be 100644 --- a/src/agents/subagent-orphan-recovery.ts +++ b/src/agents/subagent-orphan-recovery.ts @@ -174,18 +174,51 @@ export async function recoverOrphanedSubagentSessions(params: { return result; } +/** Maximum number of retry attempts for orphan recovery. */ +const MAX_RECOVERY_RETRIES = 3; +/** Backoff multiplier between retries (exponential). */ +const RETRY_BACKOFF_MULTIPLIER = 2; + /** - * Schedule orphan recovery after a delay. + * Schedule orphan recovery after a delay, with retry logic. * The delay gives the gateway time to fully bootstrap after restart. + * If recovery fails (e.g. gateway not yet ready), retries with exponential backoff. */ export function scheduleOrphanRecovery(params: { getActiveRuns: () => Map; delayMs?: number; + maxRetries?: number; }): void { - const delay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS; - setTimeout(() => { - void recoverOrphanedSubagentSessions(params).catch((err) => { - log.warn(`scheduled orphan recovery failed: ${String(err)}`); - }); - }, delay).unref?.(); + const initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS; + const maxRetries = params.maxRetries ?? MAX_RECOVERY_RETRIES; + + const attemptRecovery = (attempt: number, delay: number) => { + setTimeout(() => { + void recoverOrphanedSubagentSessions(params) + .then((result) => { + if (result.failed > 0 && attempt < maxRetries) { + const nextDelay = delay * RETRY_BACKOFF_MULTIPLIER; + log.info( + `orphan recovery had ${result.failed} failure(s); retrying in ${nextDelay}ms (attempt ${attempt + 1}/${maxRetries})`, + ); + attemptRecovery(attempt + 1, nextDelay); + } + }) + .catch((err) => { + if (attempt < maxRetries) { + const nextDelay = delay * RETRY_BACKOFF_MULTIPLIER; + log.warn( + `scheduled orphan recovery failed: ${String(err)}; retrying in ${nextDelay}ms (attempt ${attempt + 1}/${maxRetries})`, + ); + attemptRecovery(attempt + 1, nextDelay); + } else { + log.warn( + `scheduled orphan recovery failed after ${maxRetries} retries: ${String(err)}`, + ); + } + }); + }, delay).unref?.(); + }; + + attemptRecovery(0, initialDelay); } From c780b6a6ab2aeb1a6ae183600013f2d6f015613d Mon Sep 17 00:00:00 2001 From: Joey Krug Date: Sun, 15 Mar 2026 23:05:08 -0400 Subject: [PATCH 007/133] fix: address all review comments on PR #47719 + implement resume context and config idempotency guard --- src/agents/subagent-orphan-recovery.test.ts | 109 ++++++++++++++++++++ src/agents/subagent-orphan-recovery.ts | 101 +++++++++++++++--- src/cli/daemon-cli/lifecycle.ts | 6 +- 3 files changed, 201 insertions(+), 15 deletions(-) diff --git a/src/agents/subagent-orphan-recovery.test.ts b/src/agents/subagent-orphan-recovery.test.ts index 351e1079dc7..56b652b3b42 100644 --- a/src/agents/subagent-orphan-recovery.test.ts +++ b/src/agents/subagent-orphan-recovery.test.ts @@ -19,6 +19,14 @@ vi.mock("../gateway/call.js", () => ({ callGateway: vi.fn(async () => ({ runId: "test-run-id" })), })); +vi.mock("../gateway/session-utils.fs.js", () => ({ + readSessionMessages: vi.fn(() => []), +})); + +vi.mock("./subagent-registry.js", () => ({ + replaceSubagentRunAfterSteer: vi.fn(() => true), +})); + function createTestRunRecord(overrides: Partial = {}): SubagentRunRecord { return { runId: "run-1", @@ -45,6 +53,7 @@ describe("subagent-orphan-recovery", () => { it("recovers orphaned sessions with abortedLastRun=true", async () => { const sessions = await import("../config/sessions.js"); const gateway = await import("../gateway/call.js"); + const subagentRegistry = await import("./subagent-registry.js"); const sessionEntry = { sessionId: "session-abc", @@ -78,6 +87,10 @@ describe("subagent-orphan-recovery", () => { expect(params.sessionKey).toBe("agent:main:subagent:test-session-1"); expect(params.message).toContain("gateway reload"); expect(params.message).toContain("Test task: implement feature X"); + expect(subagentRegistry.replaceSubagentRunAfterSteer).toHaveBeenCalledWith({ + previousRunId: "run-1", + nextRunId: "test-run-id", + }); }); it("skips sessions that are not aborted", async () => { @@ -321,4 +334,100 @@ describe("subagent-orphan-recovery", () => { expect(message.length).toBeLessThan(5000); expect(message).toContain("..."); }); + + it("includes last human message in resume when available", async () => { + const sessions = await import("../config/sessions.js"); + const gateway = await import("../gateway/call.js"); + const sessionUtils = await import("../gateway/session-utils.fs.js"); + + vi.mocked(sessions.loadSessionStore).mockReturnValue({ + "agent:main:subagent:test-session-1": { + sessionId: "session-abc", + updatedAt: Date.now(), + abortedLastRun: true, + sessionFile: "session-abc.jsonl", + }, + }); + + vi.mocked(sessionUtils.readSessionMessages).mockReturnValue([ + { role: "user", content: [{ type: "text", text: "Please build feature Y" }] }, + { role: "assistant", content: [{ type: "text", text: "Working on it..." }] }, + { role: "user", content: [{ type: "text", text: "Also add tests for it" }] }, + { role: "assistant", content: [{ type: "text", text: "Sure, adding tests now." }] }, + ]); + + const activeRuns = new Map(); + activeRuns.set("run-1", createTestRunRecord()); + + const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); + await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns }); + + const callArgs = vi.mocked(gateway.callGateway).mock.calls[0]; + const params = callArgs[0].params as Record; + const message = params.message as string; + expect(message).toContain("Also add tests for it"); + expect(message).toContain("last message from the user"); + }); + + it("adds config change hint when assistant messages reference config modifications", async () => { + const sessions = await import("../config/sessions.js"); + const gateway = await import("../gateway/call.js"); + const sessionUtils = await import("../gateway/session-utils.fs.js"); + + vi.mocked(sessions.loadSessionStore).mockReturnValue({ + "agent:main:subagent:test-session-1": { + sessionId: "session-abc", + updatedAt: Date.now(), + abortedLastRun: true, + }, + }); + + vi.mocked(sessionUtils.readSessionMessages).mockReturnValue([ + { role: "user", content: "Update the config" }, + { role: "assistant", content: "I've modified openclaw.json to add the new setting." }, + ]); + + const activeRuns = new Map(); + activeRuns.set("run-1", createTestRunRecord()); + + const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); + await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns }); + + const callArgs = vi.mocked(gateway.callGateway).mock.calls[0]; + const params = callArgs[0].params as Record; + const message = params.message as string; + expect(message).toContain("config changes from your previous run were already applied"); + }); + + it("prevents duplicate resume when updateSessionStore fails", async () => { + const sessions = await import("../config/sessions.js"); + const gateway = await import("../gateway/call.js"); + + vi.mocked(gateway.callGateway).mockResolvedValue({ runId: "new-run" } as never); + vi.mocked(sessions.updateSessionStore).mockRejectedValue(new Error("write failed")); + + vi.mocked(sessions.loadSessionStore).mockReturnValue({ + "agent:main:subagent:test-session-1": { + sessionId: "session-abc", + updatedAt: Date.now(), + abortedLastRun: true, + }, + }); + + const activeRuns = new Map(); + activeRuns.set("run-1", createTestRunRecord()); + activeRuns.set( + "run-2", + createTestRunRecord({ + runId: "run-2", + }), + ); + + const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); + const result = await recoverOrphanedSubagentSessions({ getActiveRuns: () => activeRuns }); + + expect(result.recovered).toBe(1); + expect(result.skipped).toBe(1); + expect(gateway.callGateway).toHaveBeenCalledOnce(); + }); }); diff --git a/src/agents/subagent-orphan-recovery.ts b/src/agents/subagent-orphan-recovery.ts index 02dfa1528be..320d96f3727 100644 --- a/src/agents/subagent-orphan-recovery.ts +++ b/src/agents/subagent-orphan-recovery.ts @@ -19,7 +19,9 @@ import { type SessionEntry, } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; +import { readSessionMessages } from "../gateway/session-utils.fs.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { replaceSubagentRunAfterSteer } from "./subagent-registry.js"; import type { SubagentRunRecord } from "./subagent-registry.types.js"; const log = createSubsystemLogger("subagent-orphan-recovery"); @@ -30,14 +32,45 @@ const DEFAULT_RECOVERY_DELAY_MS = 5_000; /** * Build the resume message for an orphaned subagent. */ -function buildResumeMessage(task: string): string { +function buildResumeMessage(task: string, lastHumanMessage?: string): string { const maxTaskLen = 2000; const truncatedTask = task.length > maxTaskLen ? `${task.slice(0, maxTaskLen)}...` : task; - return ( + let message = `[System] Your previous turn was interrupted by a gateway reload. ` + - `Your task was:\n\n${truncatedTask}\n\nPlease continue where you left off.` - ); + `Your original task was:\n\n${truncatedTask}\n\n`; + + if (lastHumanMessage) { + message += `The last message from the user before the interruption was:\n\n${lastHumanMessage}\n\n`; + } + + message += `Please continue where you left off.`; + return message; +} + +function extractMessageText(msg: unknown): string | undefined { + if (!msg || typeof msg !== "object") { + return undefined; + } + const m = msg as Record; + if (typeof m.content === "string") { + return m.content; + } + if (Array.isArray(m.content)) { + const text = m.content + .filter( + (c: unknown) => + typeof c === "object" && + c !== null && + (c as Record).type === "text" && + typeof (c as Record).text === "string", + ) + .map((c: unknown) => (c as Record).text) + .filter(Boolean) + .join("\n"); + return text || undefined; + } + return undefined; } /** @@ -46,11 +79,17 @@ function buildResumeMessage(task: string): string { async function resumeOrphanedSession(params: { sessionKey: string; task: string; + lastHumanMessage?: string; + configChangeHint?: string; + originalRunId: string; }): Promise { - const resumeMessage = buildResumeMessage(params.task); + let resumeMessage = buildResumeMessage(params.task, params.lastHumanMessage); + if (params.configChangeHint) { + resumeMessage += params.configChangeHint; + } try { - await callGateway<{ runId: string }>({ + const result = await callGateway<{ runId: string }>({ method: "agent", params: { message: resumeMessage, @@ -61,6 +100,10 @@ async function resumeOrphanedSession(params: { }, timeoutMs: 10_000, }); + replaceSubagentRunAfterSteer({ + previousRunId: params.originalRunId, + nextRunId: result.runId, + }); log.info(`resumed orphaned session: ${params.sessionKey}`); return true; } catch (err) { @@ -84,6 +127,8 @@ export async function recoverOrphanedSubagentSessions(params: { getActiveRuns: () => Map; }): Promise<{ recovered: number; failed: number; skipped: number }> { const result = { recovered: 0, failed: 0, skipped: 0 }; + const resumedSessionKeys = new Set(); + const configChangePattern = /openclaw\.json|openclaw gateway restart|config\.patch/i; try { const activeRuns = params.getActiveRuns(); @@ -104,6 +149,10 @@ export async function recoverOrphanedSubagentSessions(params: { if (!childSessionKey) { continue; } + if (resumedSessionKeys.has(childSessionKey)) { + result.skipped++; + continue; + } try { const agentId = resolveAgentIdFromSessionKey(childSessionKey); @@ -129,6 +178,18 @@ export async function recoverOrphanedSubagentSessions(params: { log.info(`found orphaned subagent session: ${childSessionKey} (run=${runId})`); + const messages = readSessionMessages(entry.sessionId, storePath, entry.sessionFile); + const lastHumanMessage = [...messages] + .toReversed() + .find((msg) => (msg as { role?: unknown } | null)?.role === "user"); + const configChangeDetected = messages.some((msg) => { + if ((msg as { role?: unknown } | null)?.role !== "assistant") { + return false; + } + const text = extractMessageText(msg); + return typeof text === "string" && configChangePattern.test(text); + }); + // Resume the session with the original task context. // We intentionally do NOT clear abortedLastRun before attempting // the resume — if callGateway fails (e.g. gateway still booting), @@ -136,18 +197,30 @@ export async function recoverOrphanedSubagentSessions(params: { const resumed = await resumeOrphanedSession({ sessionKey: childSessionKey, task: runRecord.task, + lastHumanMessage: extractMessageText(lastHumanMessage), + configChangeHint: configChangeDetected + ? "\n\n[config changes from your previous run were already applied — do not re-modify openclaw.json or restart the gateway]" + : undefined, + originalRunId: runId, }); if (resumed) { + resumedSessionKeys.add(childSessionKey); // Only clear the aborted flag after confirmed successful resume. - await updateSessionStore(storePath, (currentStore) => { - const current = currentStore[childSessionKey]; - if (current) { - current.abortedLastRun = false; - current.updatedAt = Date.now(); - currentStore[childSessionKey] = current; - } - }); + try { + await updateSessionStore(storePath, (currentStore) => { + const current = currentStore[childSessionKey]; + if (current) { + current.abortedLastRun = false; + current.updatedAt = Date.now(); + currentStore[childSessionKey] = current; + } + }); + } catch (err) { + log.warn( + `resume succeeded but failed to update session store for ${childSessionKey}: ${String(err)}`, + ); + } result.recovered++; } else { // Flag stays as abortedLastRun=true so next restart can retry diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 53efaff9495..76099fe956c 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -50,8 +50,12 @@ function resolveGatewayPortFallback(): Promise { } async function assertUnmanagedGatewayRestartEnabled(port: number): Promise { + const cfg = await readBestEffortConfig().catch(() => undefined); + const tlsEnabled = !!(cfg as { gateway?: { tls?: { enabled?: unknown } } } | undefined)?.gateway + ?.tls?.enabled; + const scheme = tlsEnabled ? "wss" : "ws"; const probe = await probeGateway({ - url: `ws://127.0.0.1:${port}`, + url: `${scheme}://127.0.0.1:${port}`, auth: { token: process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined, password: process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || undefined, From 98f6ec50aadbc90e4aafa3bedfe1f432b6a4cea6 Mon Sep 17 00:00:00 2001 From: bot_apk Date: Mon, 16 Mar 2026 03:36:56 +0000 Subject: [PATCH 008/133] fix: address 6 review comments on PR #47719 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. [P1] Treat remap failures as resume failures — if replaceSubagentRunAfterSteer returns false, do NOT clear abortedLastRun, increment failed count. 2. [P2] Count scan-level exceptions as retryable failures — set result.failed > 0 in the outer catch block so scheduleOrphanRecovery retry logic triggers. 3. [P2] Persist resumed-session dedupe across recovery retries — accept resumedSessionKeys as a parameter; scheduleOrphanRecovery lifts the Set to its own scope and passes it through retries. 4. [Greptile] Use typed config accessors instead of raw structural cast for TLS check in lifecycle.ts. 5. [Greptile] Forward gateway.reload.deferralTimeoutMs to deferGatewayRestartUntilIdle in scheduleGatewaySigusr1Restart so user-configured value is not silently ignored. 6. [Greptile] Same as #4 — already addressed by the typed config fix. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/agents/subagent-orphan-recovery.ts | 20 +++++++++++++++++--- src/cli/daemon-cli/lifecycle.ts | 3 +-- src/infra/restart.ts | 7 ++++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/agents/subagent-orphan-recovery.ts b/src/agents/subagent-orphan-recovery.ts index 320d96f3727..ed2eac6d8f3 100644 --- a/src/agents/subagent-orphan-recovery.ts +++ b/src/agents/subagent-orphan-recovery.ts @@ -100,10 +100,16 @@ async function resumeOrphanedSession(params: { }, timeoutMs: 10_000, }); - replaceSubagentRunAfterSteer({ + const remapped = replaceSubagentRunAfterSteer({ previousRunId: params.originalRunId, nextRunId: result.runId, }); + if (!remapped) { + log.warn( + `resumed orphaned session ${params.sessionKey} but remap failed (old run already removed); treating as failure`, + ); + return false; + } log.info(`resumed orphaned session: ${params.sessionKey}`); return true; } catch (err) { @@ -125,9 +131,11 @@ async function resumeOrphanedSession(params: { */ export async function recoverOrphanedSubagentSessions(params: { getActiveRuns: () => Map; + /** Persisted across retries so already-resumed sessions are not resumed again. */ + resumedSessionKeys?: Set; }): Promise<{ recovered: number; failed: number; skipped: number }> { const result = { recovered: 0, failed: 0, skipped: 0 }; - const resumedSessionKeys = new Set(); + const resumedSessionKeys = params.resumedSessionKeys ?? new Set(); const configChangePattern = /openclaw\.json|openclaw gateway restart|config\.patch/i; try { @@ -236,6 +244,10 @@ export async function recoverOrphanedSubagentSessions(params: { } } catch (err) { log.warn(`orphan recovery scan failed: ${String(err)}`); + // Ensure retry logic fires for scan-level exceptions. + if (result.failed === 0) { + result.failed = 1; + } } if (result.recovered > 0 || result.failed > 0) { @@ -265,9 +277,11 @@ export function scheduleOrphanRecovery(params: { const initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS; const maxRetries = params.maxRetries ?? MAX_RECOVERY_RETRIES; + const resumedSessionKeys = new Set(); + const attemptRecovery = (attempt: number, delay: number) => { setTimeout(() => { - void recoverOrphanedSubagentSessions(params) + void recoverOrphanedSubagentSessions({ ...params, resumedSessionKeys }) .then((result) => { if (result.failed > 0 && attempt < maxRetries) { const nextDelay = delay * RETRY_BACKOFF_MULTIPLIER; diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 76099fe956c..d3e01f66412 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -51,8 +51,7 @@ function resolveGatewayPortFallback(): Promise { async function assertUnmanagedGatewayRestartEnabled(port: number): Promise { const cfg = await readBestEffortConfig().catch(() => undefined); - const tlsEnabled = !!(cfg as { gateway?: { tls?: { enabled?: unknown } } } | undefined)?.gateway - ?.tls?.enabled; + const tlsEnabled = !!cfg?.gateway?.tls?.enabled; const scheme = tlsEnabled ? "wss" : "ws"; const probe = await probeGateway({ url: `${scheme}://127.0.0.1:${port}`, diff --git a/src/infra/restart.ts b/src/infra/restart.ts index 1238b4954d0..f671df382e2 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; +import { loadConfig } from "../config/config.js"; import { resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, @@ -476,7 +477,11 @@ export function scheduleGatewaySigusr1Restart(opts?: { emitGatewayRestart(); return; } - deferGatewayRestartUntilIdle({ getPendingCount: pendingCheck }); + const cfg = loadConfig(); + deferGatewayRestartUntilIdle({ + getPendingCount: pendingCheck, + maxWaitMs: cfg.gateway?.reload?.deferralTimeoutMs, + }); }, Math.max(0, requestedDueAt - nowMs), ); From 680eff63fbf82536caf45a6f6909e3a030bef27e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:31:41 +0000 Subject: [PATCH 009/133] fix: land SIGUSR1 orphan recovery regressions (#47719) (thanks @joeykrug) --- CHANGELOG.md | 1 + src/agents/subagent-orphan-recovery.test.ts | 14 +++++---- src/agents/subagent-orphan-recovery.ts | 3 ++ src/cli/gateway-cli/run-loop.test.ts | 33 +++++++++++++++++++++ src/cli/gateway-cli/run-loop.ts | 19 +++++++++--- src/config/schema.labels.ts | 1 + src/infra/infra-runtime.test.ts | 4 +-- 7 files changed, 64 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8040d79aef..4aaf84db974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026. - Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`) - Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc. +- Gateway/restart: defer externally signaled unmanaged restarts through the in-process idle drain, and preserve the restored subagent run as remap fallback during orphan recovery so resumed sessions do not duplicate work. (#47719) Thanks @joeykrug. - 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. - CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. - Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0. diff --git a/src/agents/subagent-orphan-recovery.test.ts b/src/agents/subagent-orphan-recovery.test.ts index 56b652b3b42..66b8097154c 100644 --- a/src/agents/subagent-orphan-recovery.test.ts +++ b/src/agents/subagent-orphan-recovery.test.ts @@ -65,8 +65,9 @@ describe("subagent-orphan-recovery", () => { "agent:main:subagent:test-session-1": sessionEntry, }); + const run = createTestRunRecord(); const activeRuns = new Map(); - activeRuns.set("run-1", createTestRunRecord()); + activeRuns.set("run-1", run); const { recoverOrphanedSubagentSessions } = await import("./subagent-orphan-recovery.js"); @@ -87,10 +88,13 @@ describe("subagent-orphan-recovery", () => { expect(params.sessionKey).toBe("agent:main:subagent:test-session-1"); expect(params.message).toContain("gateway reload"); expect(params.message).toContain("Test task: implement feature X"); - expect(subagentRegistry.replaceSubagentRunAfterSteer).toHaveBeenCalledWith({ - previousRunId: "run-1", - nextRunId: "test-run-id", - }); + expect(subagentRegistry.replaceSubagentRunAfterSteer).toHaveBeenCalledWith( + expect.objectContaining({ + previousRunId: "run-1", + nextRunId: "test-run-id", + fallback: run, + }), + ); }); it("skips sessions that are not aborted", async () => { diff --git a/src/agents/subagent-orphan-recovery.ts b/src/agents/subagent-orphan-recovery.ts index ed2eac6d8f3..60408c09ae9 100644 --- a/src/agents/subagent-orphan-recovery.ts +++ b/src/agents/subagent-orphan-recovery.ts @@ -82,6 +82,7 @@ async function resumeOrphanedSession(params: { lastHumanMessage?: string; configChangeHint?: string; originalRunId: string; + originalRun: SubagentRunRecord; }): Promise { let resumeMessage = buildResumeMessage(params.task, params.lastHumanMessage); if (params.configChangeHint) { @@ -103,6 +104,7 @@ async function resumeOrphanedSession(params: { const remapped = replaceSubagentRunAfterSteer({ previousRunId: params.originalRunId, nextRunId: result.runId, + fallback: params.originalRun, }); if (!remapped) { log.warn( @@ -210,6 +212,7 @@ export async function recoverOrphanedSubagentSessions(params: { ? "\n\n[config changes from your previous run were already applied — do not re-modify openclaw.json or restart the gateway]" : undefined, originalRunId: runId, + originalRun: runRecord, }); if (resumed) { diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index bff37742254..ce8fbccbe93 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -8,6 +8,15 @@ const acquireGatewayLock = vi.fn(async (_opts?: { port?: number }) => ({ const consumeGatewaySigusr1RestartAuthorization = vi.fn(() => true); const isGatewaySigusr1RestartExternallyAllowed = vi.fn(() => false); const markGatewaySigusr1RestartHandled = vi.fn(); +const scheduleGatewaySigusr1Restart = vi.fn((_opts?: { delayMs?: number; reason?: string }) => ({ + ok: true, + pid: process.pid, + signal: "SIGUSR1" as const, + delayMs: 0, + mode: "emit" as const, + coalesced: false, + cooldownMsApplied: 0, +})); const getActiveTaskCount = vi.fn(() => 0); const markGatewayDraining = vi.fn(); const waitForActiveTasks = vi.fn(async (_timeoutMs: number) => ({ drained: true })); @@ -35,6 +44,8 @@ vi.mock("../../infra/restart.js", () => ({ consumeGatewaySigusr1RestartAuthorization: () => consumeGatewaySigusr1RestartAuthorization(), isGatewaySigusr1RestartExternallyAllowed: () => isGatewaySigusr1RestartExternallyAllowed(), markGatewaySigusr1RestartHandled: () => markGatewaySigusr1RestartHandled(), + scheduleGatewaySigusr1Restart: (opts?: { delayMs?: number; reason?: string }) => + scheduleGatewaySigusr1Restart(opts), })); vi.mock("../../infra/process-respawn.js", () => ({ @@ -292,6 +303,28 @@ describe("runGatewayLoop", () => { }); }); + it("routes external SIGUSR1 through the restart scheduler before draining", async () => { + vi.clearAllMocks(); + consumeGatewaySigusr1RestartAuthorization.mockReturnValueOnce(false); + isGatewaySigusr1RestartExternallyAllowed.mockReturnValueOnce(true); + + await withIsolatedSignals(async ({ captureSignal }) => { + const { close, start } = await createSignaledLoopHarness(); + const sigusr1 = captureSignal("SIGUSR1"); + + sigusr1(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(scheduleGatewaySigusr1Restart).toHaveBeenCalledWith({ + delayMs: 0, + reason: "SIGUSR1", + }); + expect(close).not.toHaveBeenCalled(); + expect(start).toHaveBeenCalledTimes(1); + expect(markGatewaySigusr1RestartHandled).not.toHaveBeenCalled(); + }); + }); + it("releases the lock before exiting on spawned restart", async () => { vi.clearAllMocks(); diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 13ef073a80d..23ec7dd584d 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -10,6 +10,7 @@ import { consumeGatewaySigusr1RestartAuthorization, isGatewaySigusr1RestartExternallyAllowed, markGatewaySigusr1RestartHandled, + scheduleGatewaySigusr1Restart, } from "../../infra/restart.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { @@ -186,10 +187,20 @@ export async function runGatewayLoop(params: { const onSigusr1 = () => { gatewayLog.info("signal SIGUSR1 received"); const authorized = consumeGatewaySigusr1RestartAuthorization(); - if (!authorized && !isGatewaySigusr1RestartExternallyAllowed()) { - gatewayLog.warn( - "SIGUSR1 restart ignored (not authorized; commands.restart=false or use gateway tool).", - ); + if (!authorized) { + if (!isGatewaySigusr1RestartExternallyAllowed()) { + gatewayLog.warn( + "SIGUSR1 restart ignored (not authorized; commands.restart=false or use gateway tool).", + ); + return; + } + if (shuttingDown) { + gatewayLog.info("received SIGUSR1 during shutdown; ignoring"); + return; + } + // External SIGUSR1 requests should still reuse the in-process restart + // scheduler so idle drain and restart coalescing stay consistent. + scheduleGatewaySigusr1Restart({ delayMs: 0, reason: "SIGUSR1" }); return; } markGatewaySigusr1RestartHandled(); diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index d2c0cb29e48..c8fb887924b 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -279,6 +279,7 @@ export const FIELD_LABELS: Record = { "OpenAI Chat Completions Image Timeout (ms)", "gateway.reload.mode": "Config Reload Mode", "gateway.reload.debounceMs": "Config Reload Debounce (ms)", + "gateway.reload.deferralTimeoutMs": "Restart Deferral Timeout (ms)", "gateway.nodes.browser.mode": "Gateway Node Browser Mode", "gateway.nodes.browser.node": "Gateway Node Browser Pin", "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", diff --git a/src/infra/infra-runtime.test.ts b/src/infra/infra-runtime.test.ts index 2072f8f2da3..97f2336fd11 100644 --- a/src/infra/infra-runtime.test.ts +++ b/src/infra/infra-runtime.test.ts @@ -190,8 +190,8 @@ describe("infra runtime", () => { await vi.advanceTimersByTimeAsync(0); expect(emitSpy).not.toHaveBeenCalledWith("SIGUSR1"); - // Advance past the 90s max deferral wait - await vi.advanceTimersByTimeAsync(90_000); + // Advance past the 5-minute max deferral wait + await vi.advanceTimersByTimeAsync(300_000); expect(emitSpy).toHaveBeenCalledWith("SIGUSR1"); } finally { process.removeListener("SIGUSR1", handler); From ad97c581e21f9dbb895ac2ed18b8b1a76c5ae9ab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 22:38:49 -0700 Subject: [PATCH 010/133] refactor: move channel messaging hooks into plugins --- docs/refactor/plugin-sdk.md | 22 ++ extensions/discord/src/channel.ts | 45 ++++ extensions/slack/src/channel.ts | 47 ++++ extensions/telegram/src/channel.ts | 117 ++++++++++ src/auto-reply/reply/route-reply.ts | 28 ++- src/channels/plugins/message-actions.ts | 17 +- src/channels/plugins/types.adapters.ts | 61 ++++++ src/channels/plugins/types.core.ts | 74 +++++++ src/channels/plugins/types.plugin.ts | 6 + src/channels/plugins/types.ts | 5 + src/channels/reply-prefix.ts | 15 +- src/commands/agent/run-context.ts | 4 +- src/commands/channels/add.ts | 37 ++-- src/commands/channels/remove.ts | 17 +- src/infra/exec-approval-forwarder.ts | 201 ++++++------------ src/infra/exec-approval-surface.test.ts | 127 +++++------ src/infra/exec-approval-surface.ts | 49 +---- src/infra/outbound/channel-adapters.ts | 39 +--- .../outbound/message-action-params.test.ts | 40 +++- src/infra/outbound/message-action-params.ts | 62 +----- src/infra/outbound/message-action-runner.ts | 30 +-- src/infra/outbound/outbound-session.ts | 14 ++ 22 files changed, 658 insertions(+), 399 deletions(-) diff --git a/docs/refactor/plugin-sdk.md b/docs/refactor/plugin-sdk.md index a6a10cf9472..05d519a0d24 100644 --- a/docs/refactor/plugin-sdk.md +++ b/docs/refactor/plugin-sdk.md @@ -212,3 +212,25 @@ Notes: - External plugins can be developed and updated without core source access. Related docs: [Plugins](/tools/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration). + +## Implemented channel-owned seams + +Recent refactor work widened the channel plugin contract so core can stop owning +channel-specific UX and routing behavior: + +- `messaging.buildCrossContextComponents`: channel-owned cross-context UI markers + (for example Discord components v2 containers) +- `messaging.enableInteractiveReplies`: channel-owned reply normalization toggles + (for example Slack interactive replies) +- `messaging.resolveOutboundSessionRoute`: channel-owned outbound session routing +- `threading.resolveAutoThreadId`: channel-owned same-conversation auto-threading +- `threading.resolveReplyTransport`: channel-owned reply-vs-thread delivery mapping +- `actions.requiresTrustedRequesterSender`: channel-owned privileged action trust gates +- `execApprovals.*`: channel-owned exec approval surface state, forwarding suppression, + pending payload UX, and pre-delivery hooks +- `lifecycle.onAccountConfigChanged` / `lifecycle.onAccountRemoved`: channel-owned cleanup on + config mutation/removal +- `allowlist.supportsScope`: channel-owned allowlist scope advertisement + +These hooks should be preferred over new `channel === "discord"` / `telegram` +branches in shared core flows. diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 0af60e096bc..26a69cf79e0 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,3 +1,4 @@ +import { Separator, TextDisplay } from "@buape/carbon"; import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedDmSecurityPolicy, @@ -31,11 +32,15 @@ import { resolveDiscordGroupToolPolicy, type ChannelMessageActionAdapter, type ChannelPlugin, + type OpenClawConfig, type ResolvedDiscordAccount, } from "openclaw/plugin-sdk/discord"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; +import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js"; import { getDiscordRuntime } from "./runtime.js"; import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; +import { DiscordUiContainer } from "./ui.js"; type DiscordSendFn = ReturnType< typeof getDiscordRuntime @@ -59,8 +64,37 @@ const discordMessageActions: ChannelMessageActionAdapter = { } return ma.handleAction(ctx); }, + requiresTrustedRequesterSender: ({ action, toolContext }) => + Boolean(toolContext && (action === "timeout" || action === "kick" || action === "ban")), }; +function buildDiscordCrossContextComponents(params: { + originLabel: string; + message: string; + cfg: OpenClawConfig; + accountId?: string | null; +}) { + const trimmed = params.message.trim(); + const components: Array = []; + if (trimmed) { + components.push(new TextDisplay(params.message)); + components.push(new Separator({ divider: true, spacing: "small" })); + } + components.push(new TextDisplay(`*From ${params.originLabel}*`)); + return [new DiscordUiContainer({ cfg: params.cfg, accountId: params.accountId, components })]; +} + +function hasDiscordExecApprovalDmRoute(cfg: OpenClawConfig): boolean { + return listDiscordAccountIds(cfg).some((accountId) => { + const execApprovals = resolveDiscordAccount({ cfg, accountId }).config.execApprovals; + if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) { + return false; + } + const target = execApprovals.target ?? "dm"; + return target === "dm" || target === "both"; + }); +} + const discordConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, @@ -183,11 +217,22 @@ export const discordPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeDiscordMessagingTarget, + buildCrossContextComponents: buildDiscordCrossContextComponents, targetResolver: { looksLikeId: looksLikeDiscordTargetId, hint: "", }, }, + execApprovals: { + getInitiatingSurfaceState: ({ cfg, accountId }) => + isDiscordExecApprovalClientEnabled({ cfg, accountId }) + ? { kind: "enabled" } + : { kind: "disabled" }, + hasConfiguredDmRoute: ({ cfg }) => hasDiscordExecApprovalDmRoute(cfg), + shouldSuppressForwardingFallback: ({ cfg, target }) => + (normalizeMessageChannel(target.channel) ?? target.channel) === "discord" && + isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }), + }, directory: { self: async () => null, listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params), diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 9a67597ae19..04c9706bd95 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -38,6 +38,7 @@ import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getSlackRuntime } from "./runtime.js"; import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; +import { parseSlackTarget } from "./targets.js"; const meta = getChatChannelMeta("slack"); @@ -94,6 +95,37 @@ function resolveSlackSendContext(params: { return { send, threadTsValue, tokenOverride }; } +function resolveSlackAutoThreadId(params: { + cfg: Parameters[0]["cfg"]; + accountId?: string | null; + to: string; + toolContext?: { + currentChannelId?: string; + currentThreadTs?: string; + replyToMode?: "off" | "first" | "all"; + hasRepliedRef?: { value: boolean }; + }; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) { + return undefined; + } + if (context.replyToMode !== "all" && context.replyToMode !== "first") { + return undefined; + } + const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" }); + if (!parsedTarget || parsedTarget.kind !== "channel") { + return undefined; + } + if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) { + return undefined; + } + if (context.replyToMode === "first" && context.hasRepliedRef?.value) { + return undefined; + } + return context.currentThreadTs; +} + const slackConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, @@ -235,9 +267,24 @@ export const slackPlugin: ChannelPlugin = { resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), allowExplicitReplyTagsWhenOff: false, buildToolContext: (params) => buildSlackThreadingToolContext(params), + resolveAutoThreadId: ({ cfg, accountId, to, toolContext, replyToId }) => + replyToId + ? undefined + : resolveSlackAutoThreadId({ + cfg, + accountId, + to, + toolContext, + }), + resolveReplyTransport: ({ threadId, replyToId }) => ({ + replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined), + threadId: null, + }), }, messaging: { normalizeTarget: normalizeSlackMessagingTarget, + enableInteractiveReplies: ({ cfg, accountId }) => + isSlackInteractiveRepliesEnabled({ cfg, accountId }), targetResolver: { looksLikeId: looksLikeSlackTargetId, hint: "", diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 4b648b667e6..1f0d94057a2 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -37,13 +37,24 @@ import { type ResolvedTelegramAccount, type TelegramProbe, } from "openclaw/plugin-sdk/telegram"; +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 { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; +import { + isTelegramExecApprovalClientEnabled, + resolveTelegramExecApprovalTarget, +} from "./exec-approvals.js"; import { getTelegramRuntime } from "./runtime.js"; +import { sendTypingTelegram } from "./send.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; +import { parseTelegramTarget } from "./targets.js"; +import { deleteTelegramUpdateOffset } from "./update-offset-store.js"; type TelegramSendFn = ReturnType< typeof getTelegramRuntime @@ -140,6 +151,32 @@ async function sendTelegramOutbound(params: { ); } +function resolveTelegramAutoThreadId(params: { + to: string; + toolContext?: { currentThreadTs?: string; currentChannelId?: string }; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) { + return undefined; + } + const parsedTo = parseTelegramTarget(params.to); + const parsedChannel = parseTelegramTarget(context.currentChannelId); + if (parsedTo.chatId.toLowerCase() !== parsedChannel.chatId.toLowerCase()) { + return undefined; + } + return context.currentThreadTs; +} + +function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean { + return listTelegramAccountIds(cfg).some((accountId) => { + if (!isTelegramExecApprovalClientEnabled({ cfg, accountId })) { + return false; + } + const target = resolveTelegramExecApprovalTarget({ cfg, accountId }); + return target === "dm" || target === "both"; + }); +} + const telegramMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [], @@ -282,6 +319,8 @@ export const telegramPlugin: ChannelPlugin cfg.channels?.telegram?.replyToMode ?? "off", + resolveAutoThreadId: ({ to, toolContext, replyToId }) => + replyToId ? undefined : resolveTelegramAutoThreadId({ to, toolContext }), }, messaging: { normalizeTarget: normalizeTelegramMessagingTarget, @@ -290,6 +329,84 @@ export const telegramPlugin: ChannelPlugin", }, }, + lifecycle: { + onAccountConfigChanged: async ({ prevCfg, nextCfg, accountId }) => { + const previousToken = resolveTelegramAccount({ cfg: prevCfg, accountId }).token.trim(); + const nextToken = resolveTelegramAccount({ cfg: nextCfg, accountId }).token.trim(); + if (previousToken !== nextToken) { + await deleteTelegramUpdateOffset({ accountId }); + } + }, + onAccountRemoved: async ({ accountId }) => { + await deleteTelegramUpdateOffset({ accountId }); + }, + }, + execApprovals: { + getInitiatingSurfaceState: ({ cfg, accountId }) => + isTelegramExecApprovalClientEnabled({ cfg, accountId }) + ? { kind: "enabled" } + : { kind: "disabled" }, + hasConfiguredDmRoute: ({ cfg }) => hasTelegramExecApprovalDmRoute(cfg), + shouldSuppressForwardingFallback: ({ cfg, target, request }) => { + const channel = normalizeMessageChannel(target.channel) ?? target.channel; + if (channel !== "telegram") { + return false; + } + const requestChannel = normalizeMessageChannel(request.request.turnSourceChannel ?? ""); + if (requestChannel !== "telegram") { + return false; + } + const accountId = target.accountId?.trim() || request.request.turnSourceAccountId?.trim(); + return isTelegramExecApprovalClientEnabled({ cfg, accountId }); + }, + buildPendingPayload: ({ request, nowMs }) => { + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: request.id, + approvalSlug: request.id.slice(0, 8), + approvalCommandId: request.id, + command: resolveExecApprovalCommandDisplay(request.request).commandText, + cwd: request.request.cwd ?? undefined, + host: request.request.host === "node" ? "node" : "gateway", + nodeId: request.request.nodeId ?? undefined, + expiresAtMs: request.expiresAtMs, + nowMs, + }); + const buttons = buildTelegramExecApprovalButtons(request.id); + if (!buttons) { + return payload; + } + return { + ...payload, + channelData: { + ...payload.channelData, + telegram: { + buttons, + }, + }, + }; + }, + beforeDeliverPending: async ({ cfg, target, payload }) => { + const hasExecApprovalData = + payload.channelData && + typeof payload.channelData === "object" && + !Array.isArray(payload.channelData) && + payload.channelData.execApproval; + if (!hasExecApprovalData) { + return; + } + const threadId = + typeof target.threadId === "number" + ? target.threadId + : typeof target.threadId === "string" + ? Number.parseInt(target.threadId, 10) + : undefined; + await sendTypingTelegram(target.to, { + cfg, + accountId: target.accountId ?? undefined, + ...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}), + }).catch(() => {}); + }, + }, directory: { self: async () => null, listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params), diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index ae31a15ec90..15036d0878f 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -11,7 +11,7 @@ import { parseSlackBlocksInput } from "../../../extensions/slack/src/blocks-inpu import { isSlackInteractiveRepliesEnabled } from "../../../extensions/slack/src/interactive-replies.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; -import { normalizeChannelId } from "../../channels/plugins/index.js"; +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; @@ -80,6 +80,8 @@ export async function routeReply(params: RouteReplyParams): Promise 0; - let hasSlackBlocks = false; + let hasChannelData = + externalPayload.channelData != null && Object.keys(externalPayload.channelData).length > 0; if ( channel === "slack" && externalPayload.channelData?.slack && @@ -126,17 +130,17 @@ export async function routeReply(params: RouteReplyParams): Promise>["actions"]>; + const trustedRequesterRequiredByChannel: Readonly< Partial>> > = { discord: new Set(["timeout", "kick", "ban"]), }; -type ChannelActions = NonNullable>["actions"]>; - function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean { - const actions = trustedRequesterRequiredByChannel[ctx.channel]; - return Boolean(actions?.has(ctx.action) && ctx.toolContext); + const plugin = getChannelPlugin(ctx.channel); + const fromPlugin = plugin?.actions?.requiresTrustedRequesterSender?.({ + action: ctx.action, + toolContext: ctx.toolContext, + }); + if (fromPlugin != null) { + return fromPlugin; + } + return Boolean( + trustedRequesterRequiredByChannel[ctx.channel]?.has(ctx.action) && ctx.toolContext, + ); } export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 257985e133c..c8255f07542 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -1,6 +1,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; +import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js"; import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js"; import type { OutboundIdentity } from "../../infra/outbound/identity.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; @@ -21,6 +22,19 @@ import type { ChannelStatusIssue, } from "./types.core.js"; +export type ChannelExecApprovalInitiatingSurfaceState = + | { kind: "enabled" } + | { kind: "disabled" } + | { kind: "unsupported" }; + +export type ChannelExecApprovalForwardTarget = { + channel: string; + to: string; + accountId?: string | null; + threadId?: string | number | null; + source?: "session" | "target"; +}; + export type ChannelSetupAdapter = { resolveAccountId?: (params: { cfg: OpenClawConfig; @@ -377,6 +391,53 @@ export type ChannelCommandAdapter = { skipWhenConfigEmpty?: boolean; }; +export type ChannelLifecycleAdapter = { + onAccountConfigChanged?: (params: { + prevCfg: OpenClawConfig; + nextCfg: OpenClawConfig; + accountId: string; + runtime: RuntimeEnv; + }) => Promise | void; + onAccountRemoved?: (params: { + prevCfg: OpenClawConfig; + accountId: string; + runtime: RuntimeEnv; + }) => Promise | void; +}; + +export type ChannelExecApprovalAdapter = { + getInitiatingSurfaceState?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + }) => ChannelExecApprovalInitiatingSurfaceState; + hasConfiguredDmRoute?: (params: { cfg: OpenClawConfig }) => boolean; + shouldSuppressForwardingFallback?: (params: { + cfg: OpenClawConfig; + target: ChannelExecApprovalForwardTarget; + request: ExecApprovalRequest; + }) => boolean; + buildPendingPayload?: (params: { + cfg: OpenClawConfig; + request: ExecApprovalRequest; + target: ChannelExecApprovalForwardTarget; + nowMs: number; + }) => ReplyPayload | null; + buildResolvedPayload?: (params: { + cfg: OpenClawConfig; + resolved: ExecApprovalResolved; + target: ChannelExecApprovalForwardTarget; + }) => ReplyPayload | null; + beforeDeliverPending?: (params: { + cfg: OpenClawConfig; + target: ChannelExecApprovalForwardTarget; + payload: ReplyPayload; + }) => Promise | void; +}; + +export type ChannelAllowlistAdapter = { + supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean; +}; + export type ChannelSecurityAdapter = { resolveDmPolicy?: ( ctx: ChannelSecurityContext, diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index fc6d1b91731..22b2c9387e7 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -1,3 +1,4 @@ +import type { TopLevelComponents } from "@buape/carbon"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { TSchema } from "@sinclair/typebox"; import type { MsgContext } from "../../auto-reply/templating.js"; @@ -237,6 +238,38 @@ export type ChannelStreamingAdapter = { }; }; +export type ChannelCrossContextComponentsFactory = (params: { + originLabel: string; + message: string; + cfg: OpenClawConfig; + accountId?: string | null; +}) => TopLevelComponents[]; + +export type ChannelReplyTransport = { + replyToId?: string | null; + threadId?: string | number | null; +}; + +export type ChannelFocusedBindingContext = { + conversationId: string; + parentConversationId?: string; + placement: "current" | "child"; + labelNoun: string; +}; + +export type ChannelOutboundSessionRoute = { + sessionKey: string; + baseSessionKey: string; + peer: { + kind: ChatType; + id: string; + }; + chatType: "direct" | "group" | "channel"; + from: string; + to: string; + threadId?: string | number; +}; + export type ChannelThreadingAdapter = { resolveReplyToMode?: (params: { cfg: OpenClawConfig; @@ -260,6 +293,24 @@ export type ChannelThreadingAdapter = { context: ChannelThreadingContext; hasRepliedRef?: { value: boolean }; }) => ChannelThreadingToolContext | undefined; + resolveAutoThreadId?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + to: string; + toolContext?: ChannelThreadingToolContext; + replyToId?: string | null; + }) => string | undefined; + resolveReplyTransport?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + threadId?: string | number | null; + replyToId?: string | null; + }) => ChannelReplyTransport | null; + resolveFocusedBinding?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + context: ChannelThreadingContext; + }) => ChannelFocusedBindingContext | null; }; export type ChannelThreadingContext = { @@ -293,6 +344,11 @@ export type ChannelThreadingToolContext = { export type ChannelMessagingAdapter = { normalizeTarget?: (raw: string) => string | undefined; + buildCrossContextComponents?: ChannelCrossContextComponentsFactory; + enableInteractiveReplies?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + }) => boolean; targetResolver?: { looksLikeId?: (raw: string, normalized?: string) => boolean; hint?: string; @@ -314,6 +370,20 @@ export type ChannelMessagingAdapter = { display?: string; kind?: ChannelDirectoryEntryKind; }) => string; + resolveOutboundSessionRoute?: (params: { + cfg: OpenClawConfig; + agentId: string; + accountId?: string | null; + target: string; + resolvedTarget?: { + to: string; + kind: ChannelDirectoryEntryKind | "channel"; + display?: string; + source: "normalized" | "directory"; + }; + replyToId?: string | null; + threadId?: string | number | null; + }) => ChannelOutboundSessionRoute | Promise | null; }; export type ChannelAgentPromptAdapter = { @@ -374,6 +444,10 @@ export type ChannelMessageActionAdapter = { listActions?: (params: { cfg: OpenClawConfig }) => ChannelMessageActionName[]; supportsAction?: (params: { action: ChannelMessageActionName }) => boolean; getCapabilities?: (params: { cfg: OpenClawConfig }) => readonly ChannelMessageCapability[]; + requiresTrustedRequesterSender?: (params: { + action: ChannelMessageActionName; + toolContext?: ChannelThreadingToolContext; + }) => boolean; extractToolSend?: (params: { args: Record }) => ChannelToolSend | null; handleAction?: (ctx: ChannelMessageActionContext) => Promise>; }; diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index cf09af29048..713eff20bbe 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -4,16 +4,19 @@ import type { ChannelCommandAdapter, ChannelConfigAdapter, ChannelDirectoryAdapter, + ChannelExecApprovalAdapter, ChannelResolverAdapter, ChannelElevatedAdapter, ChannelGatewayAdapter, ChannelGroupAdapter, ChannelHeartbeatAdapter, + ChannelLifecycleAdapter, ChannelOutboundAdapter, ChannelPairingAdapter, ChannelSecurityAdapter, ChannelSetupAdapter, ChannelStatusAdapter, + ChannelAllowlistAdapter, } from "./types.adapters.js"; import type { ChannelAgentTool, @@ -71,6 +74,9 @@ export type ChannelPlugin>[0]; @@ -50,10 +51,16 @@ export function createReplyPrefixContext(params: { channel: params.channel, accountId: params.accountId, }).responsePrefix, - enableSlackInteractiveReplies: - params.channel === "slack" - ? isSlackInteractiveRepliesEnabled({ cfg, accountId: params.accountId }) - : undefined, + enableSlackInteractiveReplies: params.channel + ? (getChannelPlugin(params.channel)?.messaging?.enableInteractiveReplies?.({ + cfg, + accountId: params.accountId, + }) ?? + (params.channel === "slack" + ? isSlackInteractiveRepliesEnabled({ cfg, accountId: params.accountId }) + : undefined) ?? + undefined) + : undefined, responsePrefixContextProvider: () => prefixContext, onModelSelected, }; diff --git a/src/commands/agent/run-context.ts b/src/commands/agent/run-context.ts index b9d3c3a69d0..b6c121a6c0a 100644 --- a/src/commands/agent/run-context.ts +++ b/src/commands/agent/run-context.ts @@ -42,8 +42,8 @@ export function resolveAgentRunContext(opts: AgentCommandOpts): AgentRunContext merged.currentThreadTs = String(opts.threadId); } - // Populate currentChannelId from the outbound target so that - // resolveTelegramAutoThreadId can match the originating chat. + // Populate currentChannelId from the outbound target so channel threading + // adapters can detect same-conversation auto-threading. if (!merged.currentChannelId && opts.to) { const trimmedTo = opts.to.trim(); if (trimmedTo) { diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index b2a092780a8..0079e7ea881 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -312,20 +312,7 @@ export async function channelsAddCommand( return; } - let previousTelegramToken = ""; - let resolveTelegramAccount: - | (( - params: Parameters< - typeof import("../../../extensions/telegram/src/accounts.js").resolveTelegramAccount - >[0], - ) => ReturnType< - typeof import("../../../extensions/telegram/src/accounts.js").resolveTelegramAccount - >) - | undefined; - if (channel === "telegram") { - ({ resolveTelegramAccount } = await import("../../../extensions/telegram/src/accounts.js")); - previousTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim(); - } + const prevConfig = nextConfig; if (accountId !== DEFAULT_ACCOUNT_ID) { nextConfig = moveSingleAccountChannelSectionToDefaultAccount({ @@ -334,6 +321,12 @@ export async function channelsAddCommand( }); } + let previousTelegramToken = ""; + if (channel === "telegram") { + const { resolveTelegramAccount } = await import("../../../extensions/telegram/src/accounts.js"); + previousTelegramToken = resolveTelegramAccount({ cfg: prevConfig, accountId }).token.trim(); + } + nextConfig = applyChannelAccountConfig({ cfg: nextConfig, channel, @@ -341,13 +334,19 @@ export async function channelsAddCommand( input, plugin, }); - - if (channel === "telegram" && resolveTelegramAccount) { - const { deleteTelegramUpdateOffset } = - await import("../../../extensions/telegram/src/update-offset-store.js"); + await plugin.lifecycle?.onAccountConfigChanged?.({ + prevCfg: prevConfig, + nextCfg: nextConfig, + accountId, + runtime, + }); + if (channel === "telegram") { + const [{ resolveTelegramAccount }, { deleteTelegramUpdateOffset }] = await Promise.all([ + import("../../../extensions/telegram/src/accounts.js"), + import("../../../extensions/telegram/src/update-offset-store.js"), + ]); const nextTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim(); if (previousTelegramToken !== nextTelegramToken) { - // Clear stale polling offsets after Telegram token rotation. await deleteTelegramUpdateOffset({ accountId }); } } diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 58354170135..b7d012d0fac 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -1,4 +1,3 @@ -import { deleteTelegramUpdateOffset } from "../../../extensions/telegram/src/update-offset-store.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, @@ -103,6 +102,7 @@ export async function channelsRemoveCommand( const accountKey = resolvedAccountId || DEFAULT_ACCOUNT_ID; let next = { ...cfg }; + const prevCfg = cfg; if (deleteConfig) { if (!plugin.config.deleteAccount) { runtime.error(`Channel ${channel} does not support delete.`); @@ -113,9 +113,14 @@ export async function channelsRemoveCommand( cfg: next, accountId: resolvedAccountId, }); - - // Clean up Telegram polling offset to prevent stale offset on bot token change (#18233) + await plugin.lifecycle?.onAccountRemoved?.({ + prevCfg, + accountId: resolvedAccountId, + runtime, + }); if (channel === "telegram") { + const { deleteTelegramUpdateOffset } = + await import("../../../extensions/telegram/src/update-offset-store.js"); await deleteTelegramUpdateOffset({ accountId: resolvedAccountId }); } } else { @@ -129,6 +134,12 @@ export async function channelsRemoveCommand( accountId: resolvedAccountId, enabled: false, }); + await plugin.lifecycle?.onAccountConfigChanged?.({ + prevCfg, + nextCfg: next, + accountId: resolvedAccountId, + runtime, + }); } await writeConfigFile(next); diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 5d197d6ae62..0c0c8e30e48 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -1,6 +1,5 @@ -import { buildTelegramExecApprovalButtons } from "../../extensions/telegram/src/approval-buttons.js"; -import { sendTypingTelegram } from "../../extensions/telegram/src/send.js"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { getChannelPlugin } from "../channels/plugins/index.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import type { @@ -8,7 +7,7 @@ import type { ExecApprovalForwardTarget, } from "../config/types.approvals.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { compileConfigRegex } from "../security/config-regex.js"; import { testRegexWithBoundedInput } from "../security/safe-regex.js"; import { @@ -17,7 +16,6 @@ import { type DeliverableMessageChannel, } from "../utils/message-channel.js"; import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js"; -import { buildExecApprovalPendingReplyPayload } from "./exec-approval-reply.js"; import { resolveExecApprovalSessionTarget } from "./exec-approval-session-target.js"; import type { ExecApprovalDecision, @@ -111,91 +109,22 @@ function buildTargetKey(target: ExecApprovalForwardTarget): string { return [channel, target.to, accountId, threadId].join(":"); } -function resolveChannelAccountConfig( - accounts: Record | undefined, - accountId?: string, -): T | undefined { - if (!accounts || !accountId?.trim()) { - return undefined; - } - const normalized = normalizeAccountId(accountId); - const direct = accounts[normalized]; - if (direct) { - return direct; - } - const fallbackKey = Object.keys(accounts).find( - (key) => key.toLowerCase() === normalized.toLowerCase(), - ); - return fallbackKey ? accounts[fallbackKey] : undefined; -} - -// Discord has component-based exec approvals; skip text fallback only when the -// Discord-specific handler is enabled for the same target account. -function shouldSkipDiscordForwarding( - target: ExecApprovalForwardTarget, - cfg: OpenClawConfig, -): boolean { - const channel = normalizeMessageChannel(target.channel) ?? target.channel; - if (channel !== "discord") { - return false; - } - const discord = cfg.channels?.discord as - | { - execApprovals?: { enabled?: boolean; approvers?: Array }; - accounts?: Record< - string, - { execApprovals?: { enabled?: boolean; approvers?: Array } } - >; - } - | undefined; - if (!discord) { - return false; - } - const account = resolveChannelAccountConfig(discord.accounts, target.accountId); - const execApprovals = account?.execApprovals ?? discord.execApprovals; - return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0); -} - -function shouldSkipTelegramForwarding(params: { +function shouldSkipForwardingFallback(params: { target: ExecApprovalForwardTarget; cfg: OpenClawConfig; request: ExecApprovalRequest; }): boolean { const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel; - if (channel !== "telegram") { + if (!channel) { return false; } - const requestChannel = normalizeMessageChannel(params.request.request.turnSourceChannel ?? ""); - if (requestChannel !== "telegram") { - return false; - } - const telegram = params.cfg.channels?.telegram; - if (!telegram) { - return false; - } - const telegramConfig = telegram as - | { - execApprovals?: { enabled?: boolean; approvers?: Array }; - accounts?: Record< - string, - { execApprovals?: { enabled?: boolean; approvers?: Array } } - >; - } - | undefined; - if (!telegramConfig) { - return false; - } - const accountId = - params.target.accountId?.trim() || params.request.request.turnSourceAccountId?.trim(); - const account = accountId - ? (resolveChannelAccountConfig<{ - execApprovals?: { enabled?: boolean; approvers?: Array }; - }>(telegramConfig.accounts, accountId) as - | { execApprovals?: { enabled?: boolean; approvers?: Array } } - | undefined) - : undefined; - const execApprovals = account?.execApprovals ?? telegramConfig.execApprovals; - return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0); + return ( + getChannelPlugin(channel)?.execApprovals?.shouldSuppressForwardingFallback?.({ + cfg: params.cfg, + target: params.target, + request: params.request, + }) ?? false + ); } function formatApprovalCommand(command: string): { inline: boolean; text: string } { @@ -309,6 +238,7 @@ async function deliverToTargets(params: { targets: ForwardTarget[]; buildPayload: (target: ForwardTarget) => ReplyPayload; deliver: typeof deliverOutboundPayloads; + beforeDeliver?: (target: ForwardTarget, payload: ReplyPayload) => Promise | void; shouldSend?: () => boolean; }) { const deliveries = params.targets.map(async (target) => { @@ -321,25 +251,7 @@ async function deliverToTargets(params: { } try { const payload = params.buildPayload(target); - if ( - channel === "telegram" && - payload.channelData && - typeof payload.channelData === "object" && - !Array.isArray(payload.channelData) && - payload.channelData.execApproval - ) { - const threadId = - typeof target.threadId === "number" - ? target.threadId - : typeof target.threadId === "string" - ? Number.parseInt(target.threadId, 10) - : undefined; - await sendTypingTelegram(target.to, { - cfg: params.cfg, - accountId: target.accountId, - ...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}), - }).catch(() => {}); - } + await params.beforeDeliver?.(target, payload); await params.deliver({ cfg: params.cfg, channel, @@ -356,41 +268,45 @@ async function deliverToTargets(params: { } function buildRequestPayloadForTarget( - _cfg: OpenClawConfig, + cfg: OpenClawConfig, request: ExecApprovalRequest, nowMsValue: number, target: ForwardTarget, ): ReplyPayload { const channel = normalizeMessageChannel(target.channel) ?? target.channel; - if (channel === "telegram") { - const payload = buildExecApprovalPendingReplyPayload({ - approvalId: request.id, - approvalSlug: request.id.slice(0, 8), - approvalCommandId: request.id, - command: resolveExecApprovalCommandDisplay(request.request).commandText, - cwd: request.request.cwd ?? undefined, - host: request.request.host === "node" ? "node" : "gateway", - nodeId: request.request.nodeId ?? undefined, - expiresAtMs: request.expiresAtMs, - nowMs: nowMsValue, - }); - const buttons = buildTelegramExecApprovalButtons(request.id); - if (!buttons) { - return payload; - } - return { - ...payload, - channelData: { - ...payload.channelData, - telegram: { - buttons, - }, - }, - }; + const pluginPayload = channel + ? getChannelPlugin(channel)?.execApprovals?.buildPendingPayload?.({ + cfg, + request, + target, + nowMs: nowMsValue, + }) + : null; + if (pluginPayload) { + return pluginPayload; } return { text: buildRequestMessage(request, nowMsValue) }; } +function buildResolvedPayloadForTarget( + cfg: OpenClawConfig, + resolved: ExecApprovalResolved, + target: ForwardTarget, +): ReplyPayload { + const channel = normalizeMessageChannel(target.channel) ?? target.channel; + const pluginPayload = channel + ? getChannelPlugin(channel)?.execApprovals?.buildResolvedPayload?.({ + cfg, + resolved, + target, + }) + : null; + if (pluginPayload) { + return pluginPayload; + } + return { text: buildResolvedMessage(resolved) }; +} + function resolveForwardTargets(params: { cfg: OpenClawConfig; config?: ExecApprovalForwardingConfig; @@ -454,11 +370,7 @@ export function createExecApprovalForwarder( resolveSessionTarget, }) : []), - ].filter( - (target) => - !shouldSkipDiscordForwarding(target, cfg) && - !shouldSkipTelegramForwarding({ target, cfg, request }), - ); + ].filter((target) => !shouldSkipForwardingFallback({ target, cfg, request })); if (filteredTargets.length === 0) { return false; @@ -493,6 +405,17 @@ export function createExecApprovalForwarder( cfg, targets: filteredTargets, buildPayload: (target) => buildRequestPayloadForTarget(cfg, request, nowMs(), target), + beforeDeliver: async (target, payload) => { + const channel = normalizeMessageChannel(target.channel) ?? target.channel; + if (!channel) { + return; + } + await getChannelPlugin(channel)?.execApprovals?.beforeDeliverPending?.({ + cfg, + target, + payload, + }); + }, deliver, shouldSend: () => pending.get(request.id) === pendingEntry, }).catch((err) => { @@ -529,17 +452,17 @@ export function createExecApprovalForwarder( resolveSessionTarget, }) : []), - ].filter( - (target) => - !shouldSkipDiscordForwarding(target, cfg) && - !shouldSkipTelegramForwarding({ target, cfg, request }), - ); + ].filter((target) => !shouldSkipForwardingFallback({ target, cfg, request })); } if (!targets || targets.length === 0) { return; } - const text = buildResolvedMessage(resolved); - await deliverToTargets({ cfg, targets, buildPayload: () => ({ text }), deliver }); + await deliverToTargets({ + cfg, + targets, + buildPayload: (target) => buildResolvedPayloadForTarget(cfg, resolved, target), + deliver, + }); }; const stop = () => { diff --git a/src/infra/exec-approval-surface.test.ts b/src/infra/exec-approval-surface.test.ts index 17f6789967c..c4b959c5042 100644 --- a/src/infra/exec-approval-surface.test.ts +++ b/src/infra/exec-approval-surface.test.ts @@ -1,32 +1,41 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const loadConfigMock = vi.hoisted(() => vi.fn()); -const listEnabledDiscordAccountsMock = vi.hoisted(() => vi.fn()); -const isDiscordExecApprovalClientEnabledMock = vi.hoisted(() => vi.fn()); -const listEnabledTelegramAccountsMock = vi.hoisted(() => vi.fn()); -const isTelegramExecApprovalClientEnabledMock = vi.hoisted(() => vi.fn()); +const getChannelPluginMock = vi.hoisted(() => vi.fn()); +const listChannelPluginsMock = vi.hoisted(() => vi.fn()); const normalizeMessageChannelMock = vi.hoisted(() => vi.fn()); vi.mock("../config/config.js", () => ({ loadConfig: (...args: unknown[]) => loadConfigMock(...args), })); -vi.mock("../../extensions/discord/src/accounts.js", () => ({ - listEnabledDiscordAccounts: (...args: unknown[]) => listEnabledDiscordAccountsMock(...args), +vi.mock("../channels/plugins/index.js", () => ({ + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), + listChannelPlugins: (...args: unknown[]) => listChannelPluginsMock(...args), })); -vi.mock("../../extensions/discord/src/exec-approvals.js", () => ({ - isDiscordExecApprovalClientEnabled: (...args: unknown[]) => - isDiscordExecApprovalClientEnabledMock(...args), +vi.mock("../../extensions/discord/src/channel.js", () => ({ + discordPlugin: {}, })); -vi.mock("../../extensions/telegram/src/accounts.js", () => ({ - listEnabledTelegramAccounts: (...args: unknown[]) => listEnabledTelegramAccountsMock(...args), +vi.mock("../../extensions/telegram/src/channel.js", () => ({ + telegramPlugin: {}, })); -vi.mock("../../extensions/telegram/src/exec-approvals.js", () => ({ - isTelegramExecApprovalClientEnabled: (...args: unknown[]) => - isTelegramExecApprovalClientEnabledMock(...args), +vi.mock("../../extensions/slack/src/channel.js", () => ({ + slackPlugin: {}, +})); + +vi.mock("../../extensions/whatsapp/src/channel.js", () => ({ + whatsappPlugin: {}, +})); + +vi.mock("../../extensions/signal/src/channel.js", () => ({ + signalPlugin: {}, +})); + +vi.mock("../../extensions/imessage/src/channel.js", () => ({ + imessagePlugin: {}, })); vi.mock("../utils/message-channel.js", () => ({ @@ -42,10 +51,8 @@ import { describe("resolveExecApprovalInitiatingSurfaceState", () => { beforeEach(() => { loadConfigMock.mockReset(); - listEnabledDiscordAccountsMock.mockReset(); - isDiscordExecApprovalClientEnabledMock.mockReset(); - listEnabledTelegramAccountsMock.mockReset(); - isTelegramExecApprovalClientEnabledMock.mockReset(); + getChannelPluginMock.mockReset(); + listChannelPluginsMock.mockReset(); normalizeMessageChannelMock.mockReset(); normalizeMessageChannelMock.mockImplementation((value?: string | null) => typeof value === "string" ? value.trim().toLowerCase() : undefined, @@ -71,8 +78,21 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => { }); it("uses the provided cfg for telegram and discord client enablement", () => { - isTelegramExecApprovalClientEnabledMock.mockReturnValueOnce(true); - isDiscordExecApprovalClientEnabledMock.mockReturnValueOnce(false); + getChannelPluginMock.mockImplementation((channel: string) => + channel === "telegram" + ? { + execApprovals: { + getInitiatingSurfaceState: () => ({ kind: "enabled" }), + }, + } + : channel === "discord" + ? { + execApprovals: { + getInitiatingSurfaceState: () => ({ kind: "disabled" }), + }, + } + : undefined, + ); const cfg = { channels: {} }; expect( @@ -103,7 +123,15 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => { it("loads config lazily when cfg is omitted and marks unsupported channels", () => { loadConfigMock.mockReturnValueOnce({ loaded: true }); - isTelegramExecApprovalClientEnabledMock.mockReturnValueOnce(false); + getChannelPluginMock.mockImplementation((channel: string) => + channel === "telegram" + ? { + execApprovals: { + getInitiatingSurfaceState: () => ({ kind: "disabled" }), + }, + } + : undefined, + ); expect( resolveExecApprovalInitiatingSurfaceState({ @@ -127,30 +155,19 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => { describe("hasConfiguredExecApprovalDmRoute", () => { beforeEach(() => { - listEnabledDiscordAccountsMock.mockReset(); - listEnabledTelegramAccountsMock.mockReset(); + listChannelPluginsMock.mockReset(); }); it("returns true when any enabled account routes approvals to DM or both", () => { - listEnabledDiscordAccountsMock.mockReturnValueOnce([ + listChannelPluginsMock.mockReturnValueOnce([ { - config: { - execApprovals: { - enabled: true, - approvers: ["a"], - target: "channel", - }, + execApprovals: { + hasConfiguredDmRoute: () => false, }, }, - ]); - listEnabledTelegramAccountsMock.mockReturnValueOnce([ { - config: { - execApprovals: { - enabled: true, - approvers: ["a"], - target: "both", - }, + execApprovals: { + hasConfiguredDmRoute: () => true, }, }, ]); @@ -158,37 +175,21 @@ describe("hasConfiguredExecApprovalDmRoute", () => { expect(hasConfiguredExecApprovalDmRoute({} as never)).toBe(true); }); - it("returns false when exec approvals are disabled or have no DM route", () => { - listEnabledDiscordAccountsMock.mockReturnValueOnce([ + it("returns false when no plugin reports a DM route", () => { + listChannelPluginsMock.mockReturnValueOnce([ { - config: { - execApprovals: { - enabled: false, - approvers: ["a"], - target: "dm", - }, - }, - }, - ]); - listEnabledTelegramAccountsMock.mockReturnValueOnce([ - { - config: { - execApprovals: { - enabled: true, - approvers: [], - target: "dm", - }, + execApprovals: { + hasConfiguredDmRoute: () => false, }, }, { - config: { - execApprovals: { - enabled: true, - approvers: ["a"], - target: "channel", - }, + execApprovals: { + hasConfiguredDmRoute: () => false, }, }, + { + execApprovals: undefined, + }, ]); expect(hasConfiguredExecApprovalDmRoute({} as never)).toBe(false); diff --git a/src/infra/exec-approval-surface.ts b/src/infra/exec-approval-surface.ts index 8cf43c79a3e..2147baffe02 100644 --- a/src/infra/exec-approval-surface.ts +++ b/src/infra/exec-approval-surface.ts @@ -1,7 +1,4 @@ -import { listEnabledDiscordAccounts } from "../../extensions/discord/src/accounts.js"; -import { isDiscordExecApprovalClientEnabled } from "../../extensions/discord/src/exec-approvals.js"; -import { listEnabledTelegramAccounts } from "../../extensions/telegram/src/accounts.js"; -import { isTelegramExecApprovalClientEnabled } from "../../extensions/telegram/src/exec-approvals.js"; +import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../utils/message-channel.js"; @@ -37,46 +34,18 @@ export function resolveExecApprovalInitiatingSurfaceState(params: { } const cfg = params.cfg ?? loadConfig(); - if (channel === "telegram") { - return isTelegramExecApprovalClientEnabled({ cfg, accountId: params.accountId }) - ? { kind: "enabled", channel, channelLabel } - : { kind: "disabled", channel, channelLabel }; - } - if (channel === "discord") { - return isDiscordExecApprovalClientEnabled({ cfg, accountId: params.accountId }) - ? { kind: "enabled", channel, channelLabel } - : { kind: "disabled", channel, channelLabel }; + const state = getChannelPlugin(channel)?.execApprovals?.getInitiatingSurfaceState?.({ + cfg, + accountId: params.accountId, + }); + if (state) { + return { ...state, channel, channelLabel }; } return { kind: "unsupported", channel, channelLabel }; } -function hasExecApprovalDmRoute( - accounts: Array<{ - config: { - execApprovals?: { - enabled?: boolean; - approvers?: unknown[]; - target?: string; - }; - }; - }>, -): boolean { - for (const account of accounts) { - const execApprovals = account.config.execApprovals; - if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) { - continue; - } - const target = execApprovals.target ?? "dm"; - if (target === "dm" || target === "both") { - return true; - } - } - return false; -} - export function hasConfiguredExecApprovalDmRoute(cfg: OpenClawConfig): boolean { - return ( - hasExecApprovalDmRoute(listEnabledDiscordAccounts(cfg)) || - hasExecApprovalDmRoute(listEnabledTelegramAccounts(cfg)) + return listChannelPlugins().some( + (plugin) => plugin.execApprovals?.hasConfiguredDmRoute?.({ cfg }) ?? false, ); } diff --git a/src/infra/outbound/channel-adapters.ts b/src/infra/outbound/channel-adapters.ts index da62e2932bb..0c752854e8d 100644 --- a/src/infra/outbound/channel-adapters.ts +++ b/src/infra/outbound/channel-adapters.ts @@ -1,5 +1,5 @@ -import { Separator, TextDisplay, type TopLevelComponents } from "@buape/carbon"; -import { DiscordUiContainer } from "../../../extensions/discord/src/ui.js"; +import type { TopLevelComponents } from "@buape/carbon"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelId } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -17,40 +17,17 @@ export type ChannelMessageAdapter = { buildCrossContextComponents?: CrossContextComponentsFactory; }; -type CrossContextContainerParams = { - originLabel: string; - message: string; - cfg: OpenClawConfig; - accountId?: string | null; -}; - -class CrossContextContainer extends DiscordUiContainer { - constructor({ originLabel, message, cfg, accountId }: CrossContextContainerParams) { - const trimmed = message.trim(); - const components = [] as Array; - if (trimmed) { - components.push(new TextDisplay(message)); - components.push(new Separator({ divider: true, spacing: "small" })); - } - components.push(new TextDisplay(`*From ${originLabel}*`)); - super({ cfg, accountId, components }); - } -} - const DEFAULT_ADAPTER: ChannelMessageAdapter = { supportsComponentsV2: false, }; -const DISCORD_ADAPTER: ChannelMessageAdapter = { - supportsComponentsV2: true, - buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => [ - new CrossContextContainer({ originLabel, message, cfg, accountId }), - ], -}; - export function getChannelMessageAdapter(channel: ChannelId): ChannelMessageAdapter { - if (channel === "discord") { - return DISCORD_ADAPTER; + const adapter = getChannelPlugin(channel)?.messaging?.buildCrossContextComponents; + if (adapter) { + return { + supportsComponentsV2: true, + buildCrossContextComponents: adapter, + }; } return DEFAULT_ADAPTER; } diff --git a/src/infra/outbound/message-action-params.test.ts b/src/infra/outbound/message-action-params.test.ts index f72bd2d26aa..3442711eab4 100644 --- a/src/infra/outbound/message-action-params.test.ts +++ b/src/infra/outbound/message-action-params.test.ts @@ -2,6 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { slackPlugin } from "../../../extensions/slack/src/channel.js"; +import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -9,8 +11,6 @@ import { normalizeSandboxMediaList, normalizeSandboxMediaParams, resolveAttachmentMediaPolicy, - resolveSlackAutoThreadId, - resolveTelegramAutoThreadId, } from "./message-action-params.js"; const cfg = {} as OpenClawConfig; @@ -30,19 +30,25 @@ function createToolContext( describe("message action threading helpers", () => { it("resolves Slack auto-thread ids only for matching active channels", () => { expect( - resolveSlackAutoThreadId({ + slackPlugin?.threading?.resolveAutoThreadId?.({ + cfg, + accountId: undefined, to: "#c123", toolContext: createToolContext(), }), ).toBe("thread-1"); expect( - resolveSlackAutoThreadId({ + slackPlugin?.threading?.resolveAutoThreadId?.({ + cfg, + accountId: undefined, to: "channel:C999", toolContext: createToolContext(), }), ).toBeUndefined(); expect( - resolveSlackAutoThreadId({ + slackPlugin?.threading?.resolveAutoThreadId?.({ + cfg, + accountId: undefined, to: "user:U123", toolContext: createToolContext(), }), @@ -51,7 +57,9 @@ describe("message action threading helpers", () => { it("skips Slack auto-thread ids when reply mode or context blocks them", () => { expect( - resolveSlackAutoThreadId({ + slackPlugin?.threading?.resolveAutoThreadId?.({ + cfg, + accountId: undefined, to: "C123", toolContext: createToolContext({ replyToMode: "first", @@ -60,13 +68,17 @@ describe("message action threading helpers", () => { }), ).toBeUndefined(); expect( - resolveSlackAutoThreadId({ + slackPlugin?.threading?.resolveAutoThreadId?.({ + cfg, + accountId: undefined, to: "C123", toolContext: createToolContext({ replyToMode: "off" }), }), ).toBeUndefined(); expect( - resolveSlackAutoThreadId({ + slackPlugin?.threading?.resolveAutoThreadId?.({ + cfg, + accountId: undefined, to: "C123", toolContext: createToolContext({ currentThreadTs: undefined }), }), @@ -75,7 +87,9 @@ describe("message action threading helpers", () => { it("resolves Telegram auto-thread ids for matching chats across target formats", () => { expect( - resolveTelegramAutoThreadId({ + telegramPlugin?.threading?.resolveAutoThreadId?.({ + cfg, + accountId: undefined, to: "telegram:group:-100123:topic:77", toolContext: createToolContext({ currentChannelId: "tg:group:-100123", @@ -83,7 +97,9 @@ describe("message action threading helpers", () => { }), ).toBe("thread-1"); expect( - resolveTelegramAutoThreadId({ + telegramPlugin?.threading?.resolveAutoThreadId?.({ + cfg, + accountId: undefined, to: "-100999:77", toolContext: createToolContext({ currentChannelId: "-100123", @@ -91,7 +107,9 @@ describe("message action threading helpers", () => { }), ).toBeUndefined(); expect( - resolveTelegramAutoThreadId({ + telegramPlugin?.threading?.resolveAutoThreadId?.({ + cfg, + accountId: undefined, to: "-100123", toolContext: createToolContext({ currentChannelId: undefined }), }), diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index 9f23419f7dc..30991639129 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -1,15 +1,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import { parseSlackTarget } from "../../../extensions/slack/src/targets.js"; -import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; import { loadWebMedia } from "../../../extensions/whatsapp/src/media.js"; import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js"; import { readStringParam } from "../../agents/tools/common.js"; -import type { - ChannelId, - ChannelMessageActionName, - ChannelThreadingToolContext, -} from "../../channels/plugins/types.js"; +import type { ChannelId, ChannelMessageActionName } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createRootScopedReadFile } from "../../infra/fs-safe.js"; import { extensionForMime } from "../../media/mime.js"; @@ -17,60 +11,6 @@ import { readBooleanParam as readBooleanParamShared } from "../../plugin-sdk/boo export const readBooleanParam = readBooleanParamShared; -export function resolveSlackAutoThreadId(params: { - to: string; - toolContext?: ChannelThreadingToolContext; -}): string | undefined { - const context = params.toolContext; - if (!context?.currentThreadTs || !context.currentChannelId) { - return undefined; - } - // Only mirror auto-threading when Slack would reply in the active thread for this channel. - if (context.replyToMode !== "all" && context.replyToMode !== "first") { - return undefined; - } - const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" }); - if (!parsedTarget || parsedTarget.kind !== "channel") { - return undefined; - } - if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) { - return undefined; - } - if (context.replyToMode === "first" && context.hasRepliedRef?.value) { - return undefined; - } - return context.currentThreadTs; -} - -/** - * Auto-inject Telegram forum topic thread ID when the message tool targets - * the same chat the session originated from. Mirrors the Slack auto-threading - * pattern so media, buttons, and other tool-sent messages land in the correct - * topic instead of the General Topic. - * - * Unlike Slack, we do not gate on `replyToMode` here: Telegram forum topics - * are persistent sub-channels (not ephemeral reply threads), so auto-injection - * should always apply when the target chat matches. - */ -export function resolveTelegramAutoThreadId(params: { - to: string; - toolContext?: ChannelThreadingToolContext; -}): string | undefined { - const context = params.toolContext; - if (!context?.currentThreadTs || !context.currentChannelId) { - return undefined; - } - // Use parseTelegramTarget to extract canonical chatId from both sides, - // mirroring how Slack uses parseSlackTarget. This handles format variations - // like `telegram:group:123:topic:456` vs `telegram:123`. - const parsedTo = parseTelegramTarget(params.to); - const parsedChannel = parseTelegramTarget(context.currentChannelId); - if (parsedTo.chatId.toLowerCase() !== parsedChannel.chatId.toLowerCase()) { - return undefined; - } - return context.currentThreadTs; -} - function resolveAttachmentMaxBytes(params: { cfg: OpenClawConfig; channel: ChannelId; diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index a867d912aca..aa53f7398f4 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -6,6 +6,7 @@ import { readStringParam, } from "../../agents/tools/common.js"; import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; import type { ChannelId, @@ -37,8 +38,6 @@ import { parseInteractiveParam, readBooleanParam, resolveAttachmentMediaPolicy, - resolveSlackAutoThreadId, - resolveTelegramAutoThreadId, } from "./message-action-params.js"; import type { MessagePollResult, MessageSendResult } from "./message.js"; import { @@ -65,22 +64,23 @@ export type MessageActionRunnerGateway = { function resolveAndApplyOutboundThreadId( params: Record, ctx: { + cfg: OpenClawConfig; channel: ChannelId; to: string; + accountId?: string | null; toolContext?: ChannelThreadingToolContext; - allowSlackAutoThread: boolean; }, ): string | undefined { const threadId = readStringParam(params, "threadId"); - const slackAutoThreadId = - ctx.allowSlackAutoThread && ctx.channel === "slack" && !threadId - ? resolveSlackAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext }) - : undefined; - const telegramAutoThreadId = - ctx.channel === "telegram" && !threadId - ? resolveTelegramAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext }) - : undefined; - const resolved = threadId ?? slackAutoThreadId ?? telegramAutoThreadId; + const resolved = + threadId ?? + getChannelPlugin(ctx.channel)?.threading?.resolveAutoThreadId?.({ + cfg: ctx.cfg, + accountId: ctx.accountId, + to: ctx.to, + toolContext: ctx.toolContext, + replyToId: readStringParam(params, "replyTo"), + }); // Write auto-resolved threadId back into params so downstream dispatch // (plugin `readStringParam(params, "threadId")`) picks it up. if (resolved && !params.threadId) { @@ -501,10 +501,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise Date: Sun, 15 Mar 2026 22:41:30 -0700 Subject: [PATCH 011/133] fix: stabilize windows parallels smoke harness --- scripts/e2e/parallels-windows-smoke.sh | 29 ++++++++++++-------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/scripts/e2e/parallels-windows-smoke.sh b/scripts/e2e/parallels-windows-smoke.sh index cd144511f49..e7016d22062 100644 --- a/scripts/e2e/parallels-windows-smoke.sh +++ b/scripts/e2e/parallels-windows-smoke.sh @@ -371,9 +371,10 @@ phase_run() { local timeout_s="$2" shift 2 - local log_path pid rc timed_out + local log_path pid start rc timed_out log_path="$(phase_log_path "$phase_id")" say "$phase_id" + start=$SECONDS timed_out=0 ( @@ -381,26 +382,22 @@ phase_run() { ) >"$log_path" 2>&1 & pid=$! - ( - sleep "$timeout_s" - kill "$pid" >/dev/null 2>&1 || true - sleep 2 - kill -9 "$pid" >/dev/null 2>&1 || true - ) & - local killer_pid=$! + while kill -0 "$pid" >/dev/null 2>&1; do + if (( SECONDS - start >= timeout_s )); then + timed_out=1 + kill "$pid" >/dev/null 2>&1 || true + sleep 2 + kill -9 "$pid" >/dev/null 2>&1 || true + break + fi + sleep 1 + done set +e wait "$pid" rc=$? set -e - if kill -0 "$killer_pid" >/dev/null 2>&1; then - kill "$killer_pid" >/dev/null 2>&1 || true - wait "$killer_pid" >/dev/null 2>&1 || true - else - timed_out=1 - fi - if (( timed_out )); then warn "$phase_id timed out after ${timeout_s}s" printf 'timeout after %ss\n' "$timeout_s" >>"$log_path" @@ -770,7 +767,7 @@ show_gateway_status_compat() { } verify_turn() { - guest_run_openclaw "" "" agent --agent main --message ping --json + guest_run_openclaw "" "" agent --agent main --message "Reply with exact ASCII text OK only." --json } capture_latest_ref_failure() { From 55cbfb6e6ad17dcc3c512e3c6170f00a83451eb5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 22:42:58 -0700 Subject: [PATCH 012/133] refactor(plugins): move provider onboarding auth into plugins --- extensions/anthropic/index.ts | 37 ++++- extensions/google/index.ts | 28 +++- extensions/minimax/index.ts | 70 +++++++- extensions/openai/openai-provider.ts | 28 +++- .../auth-choice.apply.plugin-provider.ts | 4 + src/commands/onboard-auth.credentials.ts | 2 +- .../local/auth-choice.test.ts | 8 +- .../local/auth-choice.ts | 36 ++--- src/plugins/provider-api-key-auth.ts | 152 ++++++++++++++++++ src/plugins/provider-wizard.test.ts | 40 +++++ src/plugins/provider-wizard.ts | 32 ++++ src/plugins/types.ts | 17 ++ 12 files changed, 420 insertions(+), 34 deletions(-) create mode 100644 src/plugins/provider-api-key-auth.ts diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index ba2a1a55cb5..13758e7de46 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -19,10 +19,12 @@ 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 { 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"; const PROVIDER_ID = "anthropic"; +const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6"; const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6"; const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6"; const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const; @@ -313,6 +315,14 @@ const anthropicPlugin = { label: "setup-token (claude)", hint: "Paste a setup-token from `claude setup-token`", kind: "token", + wizard: { + choiceId: "token", + choiceLabel: "Anthropic token (paste setup-token)", + choiceHint: "Run `claude setup-token` elsewhere, then paste the token here", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "setup-token + API key", + }, run: async (ctx: ProviderAuthContext) => await runAnthropicSetupToken(ctx), runNonInteractive: async (ctx) => await runAnthropicSetupTokenNonInteractive({ @@ -322,15 +332,26 @@ const anthropicPlugin = { agentDir: ctx.agentDir, }), }, + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Anthropic API key", + hint: "Direct Anthropic API key", + optionKey: "anthropicApiKey", + flagName: "--anthropic-api-key", + envVar: "ANTHROPIC_API_KEY", + promptMessage: "Enter Anthropic API key", + defaultModel: DEFAULT_ANTHROPIC_MODEL, + expectedProviders: ["anthropic"], + wizard: { + choiceId: "apiKey", + choiceLabel: "Anthropic API key", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "setup-token + API key", + }, + }), ], - wizard: { - setup: { - choiceId: "token", - choiceLabel: "Anthropic token (paste setup-token)", - choiceHint: "Run `claude setup-token` elsewhere, then paste the token here", - methodId: "setup-token", - }, - }, resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx), capabilities: { providerFamily: "anthropic", diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 0afa07e2ce0..59d417e9349 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -3,7 +3,12 @@ import { 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"; import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; @@ -19,7 +24,28 @@ const googlePlugin = { label: "Google AI Studio", docsPath: "/providers/models", envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: "google", + methodId: "api-key", + label: "Google Gemini API key", + hint: "AI Studio / Gemini API key", + optionKey: "geminiApiKey", + flagName: "--gemini-api-key", + envVar: "GEMINI_API_KEY", + promptMessage: "Enter Gemini API key", + defaultModel: GOOGLE_GEMINI_DEFAULT_MODEL, + expectedProviders: ["google"], + applyConfig: (cfg) => applyGoogleGeminiModelDefault(cfg).next, + wizard: { + choiceId: "gemini-api-key", + choiceLabel: "Google Gemini API key", + groupId: "google", + groupLabel: "Google", + groupHint: "Gemini API key + OAuth", + }, + }), + ], resolveDynamicModel: (ctx) => resolveGoogle31ForwardCompatModel({ providerId: "google", ctx }), isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 0231fd86236..6906bb0438d 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -12,7 +12,12 @@ import { buildMinimaxPortalProvider, buildMinimaxProvider, } from "../../src/agents/models-config.providers.static.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"; const API_PROVIDER_ID = "minimax"; @@ -160,7 +165,54 @@ const minimaxPlugin = { label: PROVIDER_LABEL, docsPath: "/providers/minimax", envVars: ["MINIMAX_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: API_PROVIDER_ID, + methodId: "api-global", + label: "MiniMax API key (Global)", + hint: "Global endpoint - api.minimax.io", + optionKey: "minimaxApiKey", + flagName: "--minimax-api-key", + envVar: "MINIMAX_API_KEY", + promptMessage: + "Enter MiniMax API key (sk-api- or sk-cp-)\nhttps://platform.minimax.io/user-center/basic-information/interface-key", + profileId: "minimax:global", + defaultModel: modelRef(DEFAULT_MODEL), + expectedProviders: ["minimax"], + applyConfig: (cfg) => applyMinimaxApiConfig(cfg), + wizard: { + choiceId: "minimax-global-api", + choiceLabel: "MiniMax API key (Global)", + choiceHint: "Global endpoint - api.minimax.io", + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.5 (recommended)", + }, + }), + createProviderApiKeyAuthMethod({ + providerId: API_PROVIDER_ID, + methodId: "api-cn", + label: "MiniMax API key (CN)", + hint: "CN endpoint - api.minimaxi.com", + optionKey: "minimaxApiKey", + flagName: "--minimax-api-key", + envVar: "MINIMAX_API_KEY", + promptMessage: + "Enter MiniMax CN API key (sk-api- or sk-cp-)\nhttps://platform.minimaxi.com/user-center/basic-information/interface-key", + profileId: "minimax:cn", + defaultModel: modelRef(DEFAULT_MODEL), + expectedProviders: ["minimax", "minimax-cn"], + applyConfig: (cfg) => applyMinimaxApiConfigCn(cfg), + wizard: { + choiceId: "minimax-cn-api", + choiceLabel: "MiniMax API key (CN)", + choiceHint: "CN endpoint - api.minimaxi.com", + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.5 (recommended)", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => resolveApiCatalog(ctx), @@ -190,6 +242,14 @@ const minimaxPlugin = { label: "MiniMax OAuth (Global)", hint: "Global endpoint - api.minimax.io", kind: "device_code", + wizard: { + choiceId: "minimax-global-oauth", + choiceLabel: "MiniMax OAuth (Global)", + choiceHint: "Global endpoint - api.minimax.io", + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.5 (recommended)", + }, run: createOAuthHandler("global"), }, { @@ -197,6 +257,14 @@ const minimaxPlugin = { label: "MiniMax OAuth (CN)", hint: "CN endpoint - api.minimaxi.com", kind: "device_code", + wizard: { + choiceId: "minimax-cn-oauth", + choiceLabel: "MiniMax OAuth (CN)", + choiceHint: "CN endpoint - api.minimaxi.com", + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.5 (recommended)", + }, run: createOAuthHandler("cn"), }, ], diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index be406f26bbb..9155fb3cd30 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -4,6 +4,11 @@ import { } from "openclaw/plugin-sdk/core"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { normalizeProviderId } from "../../src/agents/model-selection.js"; +import { + applyOpenAIConfig, + 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"; import { cloneFirstTemplateModel, @@ -89,7 +94,28 @@ export function buildOpenAIProvider(): ProviderPlugin { label: "OpenAI", docsPath: "/providers/models", envVars: ["OPENAI_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "OpenAI API key", + hint: "Direct OpenAI API key", + optionKey: "openaiApiKey", + flagName: "--openai-api-key", + envVar: "OPENAI_API_KEY", + promptMessage: "Enter OpenAI API key", + defaultModel: OPENAI_DEFAULT_MODEL, + expectedProviders: ["openai"], + applyConfig: (cfg) => applyOpenAIConfig(cfg), + wizard: { + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + groupHint: "Codex OAuth + API key", + }, + }), + ], resolveDynamicModel: (ctx) => resolveOpenAIGpt54ForwardCompatModel(ctx), normalizeResolvedModel: (ctx) => { if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index 139dd4500f4..5f4893b249c 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -44,6 +44,7 @@ export async function runProviderPluginAuthMethod(params: { emitNotes?: boolean; secretInputMode?: OnboardOptions["secretInputMode"]; allowSecretRefPrompt?: boolean; + opts?: Partial; }): Promise<{ config: ApplyAuthChoiceParams["config"]; defaultModel?: string }> { const agentId = params.agentId ?? resolveDefaultAgentId(params.config); const defaultAgentId = resolveDefaultAgentId(params.config); @@ -64,6 +65,7 @@ export async function runProviderPluginAuthMethod(params: { workspaceDir, prompter: params.prompter, runtime: params.runtime, + opts: params.opts, secretInputMode: params.secretInputMode, allowSecretRefPrompt: params.allowSecretRefPrompt, isRemote, @@ -134,6 +136,7 @@ export async function applyAuthChoiceLoadedPluginProvider( workspaceDir, secretInputMode: params.opts?.secretInputMode, allowSecretRefPrompt: true, + opts: params.opts, }); let agentModelOverride: string | undefined; @@ -213,6 +216,7 @@ export async function applyAuthChoicePluginProvider( workspaceDir, secretInputMode: params.opts?.secretInputMode, allowSecretRefPrompt: true, + opts: params.opts, }); nextConfig = applied.config; diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index 014984cd6f3..2973667830b 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -74,7 +74,7 @@ function resolveApiKeySecretInput( return normalized; } -function buildApiKeyCredential( +export function buildApiKeyCredential( provider: string, input: SecretInput, metadata?: Record, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.test.ts index 9fe7a34cda9..b3255e7b4bb 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.test.ts @@ -32,11 +32,11 @@ function createRuntime() { } describe("applyNonInteractiveAuthChoice", () => { - it("resolves builtin API key auth before plugin provider resolution", async () => { + it("resolves plugin provider auth before builtin API key fallbacks", async () => { const runtime = createRuntime(); const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; const resolvedConfig = { auth: { profiles: { "openai:default": { mode: "api_key" } } } }; - applySimpleNonInteractiveApiKeyChoice.mockResolvedValueOnce(resolvedConfig as never); + applyNonInteractivePluginProviderChoice.mockResolvedValueOnce(resolvedConfig as never); const result = await applyNonInteractiveAuthChoice({ nextConfig, @@ -47,7 +47,7 @@ describe("applyNonInteractiveAuthChoice", () => { }); expect(result).toBe(resolvedConfig); - expect(applySimpleNonInteractiveApiKeyChoice).toHaveBeenCalledOnce(); - expect(applyNonInteractivePluginProviderChoice).not.toHaveBeenCalled(); + expect(applyNonInteractivePluginProviderChoice).toHaveBeenCalledOnce(); + expect(applySimpleNonInteractiveApiKeyChoice).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 64a3379ad15..5c61e247c89 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -156,6 +156,24 @@ export async function applyNonInteractiveAuthChoice(params: { return null; } + const pluginProviderChoice = await applyNonInteractivePluginProviderChoice({ + nextConfig, + authChoice, + opts, + runtime, + baseConfig, + resolveApiKey: (input) => + resolveApiKey({ + ...input, + cfg: baseConfig, + runtime, + }), + toApiKeyCredential, + }); + if (pluginProviderChoice !== undefined) { + return pluginProviderChoice; + } + const simpleApiKeyChoice = await applySimpleNonInteractiveApiKeyChoice({ authChoice, nextConfig, @@ -406,24 +424,6 @@ export async function applyNonInteractiveAuthChoice(params: { } } - const pluginProviderChoice = await applyNonInteractivePluginProviderChoice({ - nextConfig, - authChoice, - opts, - runtime, - baseConfig, - resolveApiKey: (input) => - resolveApiKey({ - ...input, - cfg: baseConfig, - runtime, - }), - toApiKeyCredential, - }); - if (pluginProviderChoice !== undefined) { - return pluginProviderChoice; - } - if ( authChoice === "oauth" || authChoice === "chutes" || diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts new file mode 100644 index 00000000000..0ef8b356ea0 --- /dev/null +++ b/src/plugins/provider-api-key-auth.ts @@ -0,0 +1,152 @@ +import { upsertAuthProfile } from "../agents/auth-profiles.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js"; +import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js"; +import { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js"; +import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { SecretInput } from "../config/types.secrets.js"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; +import type { + ProviderAuthMethod, + ProviderAuthMethodNonInteractiveContext, + ProviderPluginWizardSetup, +} from "./types.js"; + +type ProviderApiKeyAuthMethodOptions = { + providerId: string; + methodId: string; + label: string; + hint?: string; + wizard?: ProviderPluginWizardSetup; + optionKey: string; + flagName: `--${string}`; + envVar: string; + promptMessage: string; + profileId?: string; + defaultModel?: string; + expectedProviders?: string[]; + metadata?: Record; + noteMessage?: string; + noteTitle?: string; + applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig; +}; + +function resolveStringOption(opts: Record | undefined, optionKey: string) { + return normalizeOptionalSecretInput(opts?.[optionKey]); +} + +function resolveProfileId(params: { providerId: string; profileId?: string }) { + return params.profileId?.trim() || `${params.providerId}:default`; +} + +function applyApiKeyConfig(params: { + ctx: ProviderAuthMethodNonInteractiveContext; + providerId: string; + profileId: string; + applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig; +}) { + const next = applyAuthProfileConfig(params.ctx.config, { + profileId: params.profileId, + provider: params.providerId, + mode: "api_key", + }); + return params.applyConfig ? params.applyConfig(next) : next; +} + +export function createProviderApiKeyAuthMethod( + params: ProviderApiKeyAuthMethodOptions, +): ProviderAuthMethod { + return { + id: params.methodId, + label: params.label, + hint: params.hint, + kind: "api_key", + wizard: params.wizard, + run: async (ctx) => { + const opts = ctx.opts as Record | undefined; + const flagValue = resolveStringOption(opts, params.optionKey); + let capturedSecretInput: SecretInput | undefined; + let capturedMode: "plaintext" | "ref" | undefined; + + await ensureApiKeyFromOptionEnvOrPrompt({ + token: flagValue ?? normalizeOptionalSecretInput(ctx.opts?.token), + tokenProvider: flagValue + ? params.providerId + : normalizeOptionalSecretInput(ctx.opts?.tokenProvider), + secretInputMode: + ctx.allowSecretRefPrompt === false + ? (ctx.secretInputMode ?? "plaintext") + : ctx.secretInputMode, + config: ctx.config, + expectedProviders: params.expectedProviders ?? [params.providerId], + provider: params.providerId, + envLabel: params.envVar, + promptMessage: params.promptMessage, + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: ctx.prompter, + noteMessage: params.noteMessage, + noteTitle: params.noteTitle, + setCredential: async (apiKey, mode) => { + capturedSecretInput = apiKey; + capturedMode = mode; + }, + }); + + if (!capturedSecretInput) { + throw new Error(`Missing API key input for provider "${params.providerId}".`); + } + + return { + profiles: [ + { + profileId: resolveProfileId(params), + credential: buildApiKeyCredential( + params.providerId, + capturedSecretInput, + params.metadata, + capturedMode ? { secretInputMode: capturedMode } : undefined, + ), + }, + ], + ...(params.defaultModel ? { defaultModel: params.defaultModel } : {}), + }; + }, + runNonInteractive: async (ctx) => { + const opts = ctx.opts as Record | undefined; + const resolved = await ctx.resolveApiKey({ + provider: params.providerId, + flagValue: resolveStringOption(opts, params.optionKey), + flagName: params.flagName, + envVar: params.envVar, + }); + if (!resolved) { + return null; + } + + const profileId = resolveProfileId(params); + if (resolved.source !== "profile") { + const credential = ctx.toApiKeyCredential({ + provider: params.providerId, + resolved, + ...(params.metadata ? { metadata: params.metadata } : {}), + }); + if (!credential) { + return null; + } + upsertAuthProfile({ + profileId, + credential, + agentDir: ctx.agentDir, + }); + } + + return applyApiKeyConfig({ + ctx, + providerId: params.providerId, + profileId, + applyConfig: params.applyConfig, + }); + }, + }; +} diff --git a/src/plugins/provider-wizard.test.ts b/src/plugins/provider-wizard.test.ts index f55d9292824..eff361ee1c9 100644 --- a/src/plugins/provider-wizard.test.ts +++ b/src/plugins/provider-wizard.test.ts @@ -64,6 +64,46 @@ describe("provider wizard boundaries", () => { }); }); + it("builds wizard options from method-level metadata", () => { + const provider = makeProvider({ + id: "openai", + label: "OpenAI", + auth: [ + { + id: "api-key", + label: "OpenAI API key", + kind: "api_key", + wizard: { + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + }, + run: vi.fn(), + }, + ], + }); + resolvePluginProviders.mockReturnValue([provider]); + + expect(resolveProviderWizardOptions({})).toEqual([ + { + value: "openai-api-key", + label: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + }, + ]); + expect( + resolveProviderPluginChoice({ + providers: [provider], + choice: "openai-api-key", + }), + ).toEqual({ + provider, + method: provider.auth[0], + }); + }); + it("builds model-picker entries from plugin metadata and provider-method choices", () => { const provider = makeProvider({ id: "sglang", diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index dcac7e36d40..cbe90178056 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -61,6 +61,17 @@ function resolveMethodById( return provider.auth.find((method) => method.id.trim().toLowerCase() === normalizedMethodId); } +function listMethodWizardSetups(provider: ProviderPlugin): Array<{ + method: ProviderAuthMethod; + wizard: ProviderPluginWizardSetup; +}> { + return provider.auth + .map((method) => (method.wizard ? { method, wizard: method.wizard } : null)) + .filter((entry): entry is { method: ProviderAuthMethod; wizard: ProviderPluginWizardSetup } => + Boolean(entry), + ); +} + function buildSetupOptionForMethod(params: { provider: ProviderPlugin; wizard: ProviderPluginWizardSetup; @@ -93,6 +104,20 @@ export function resolveProviderWizardOptions(params: { const options: ProviderWizardOption[] = []; for (const provider of providers) { + const methodSetups = listMethodWizardSetups(provider); + for (const { method, wizard } of methodSetups) { + options.push( + buildSetupOptionForMethod({ + provider, + wizard, + method, + value: wizard.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, method.id), + }), + ); + } + if (methodSetups.length > 0) { + continue; + } const setup = provider.wizard?.setup; if (!setup) { continue; @@ -187,6 +212,13 @@ export function resolveProviderPluginChoice(params: { } for (const provider of params.providers) { + for (const { method, wizard } of listMethodWizardSetups(provider)) { + const choiceId = + wizard.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, method.id); + if (normalizeChoiceId(choiceId) === choice) { + return { provider, method }; + } + } const setup = provider.wizard?.setup; if (setup) { const setupChoiceId = resolveWizardSetupChoiceId(provider, setup); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index bea63007fb2..f533b1b80a1 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -119,6 +119,15 @@ export type ProviderAuthContext = { workspaceDir?: string; prompter: WizardPrompter; runtime: RuntimeEnv; + /** + * Optional onboarding CLI options that triggered this auth flow. + * + * Present for setup/configure/auth-choice flows so provider methods can + * honor preseeded flags like `--openai-api-key` or generic + * `--token/--token-provider` pairs. Direct `models auth login` usually + * leaves this undefined. + */ + opts?: Partial; /** * Onboarding secret persistence preference. * @@ -187,6 +196,14 @@ export type ProviderAuthMethod = { label: string; hint?: string; kind: ProviderAuthKind; + /** + * Optional wizard/onboarding metadata for this specific auth method. + * + * Use this when one provider exposes multiple setup entries (for example API + * key + OAuth, or region-specific login flows). OpenClaw uses this to expose + * method-specific auth choices while keeping the provider id stable. + */ + wizard?: ProviderPluginWizardSetup; run: (ctx: ProviderAuthContext) => Promise; runNonInteractive?: ( ctx: ProviderAuthMethodNonInteractiveContext, From 2acbea0da74a3ba722a033fab6085fd170177c86 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:50:33 +0000 Subject: [PATCH 013/133] docs: restore onboard as canonical setup command --- src/cli/program/register.onboard.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 982be1a75c3..914732e4079 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -1,5 +1,4 @@ import type { Command } from "commander"; -import { formatCliCommand } from "../../cli/command-format.js"; import { formatStaticAuthChoiceChoicesForCli } from "../../commands/auth-choice-options.static.js"; import type { GatewayDaemonRuntime } from "../../commands/daemon-runtime.js"; import { ONBOARD_PROVIDER_AUTH_FLAGS } from "../../commands/onboard-provider-auth-flags.js"; @@ -50,14 +49,11 @@ const AUTH_CHOICE_HELP = formatStaticAuthChoiceChoicesForCli({ export function registerOnboardCommand(program: Command) { const command = program .command("onboard") - .description('Legacy alias for "openclaw setup --wizard"') - .addHelpText("after", () => - [ - "", - `${theme.muted("Docs:")} ${formatDocsLink("/cli/setup", "docs.openclaw.ai/cli/setup")}`, - `${theme.muted("Prefer:")} ${formatCliCommand("openclaw setup --wizard")}`, - "", - ].join("\n"), + .description("Interactive wizard to set up the gateway, workspace, and skills") + .addHelpText( + "after", + () => + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/onboard", "docs.openclaw.ai/cli/onboard")}\n`, ) .option("--workspace ", "Agent workspace directory (default: ~/.openclaw/workspace)") .option( From f9e185887fcedaad53c711e5fa13d9a995cb5b87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:50:48 +0000 Subject: [PATCH 014/133] docs: restore onboard docs references --- docs/.i18n/glossary.zh-CN.json | 4 - docs/automation/hooks.md | 2 +- docs/channels/bluebubbles.md | 4 +- docs/channels/feishu.md | 2 +- docs/channels/nostr.md | 2 +- docs/channels/synology-chat.md | 4 +- docs/cli/index.md | 2 +- docs/cli/onboard.md | 161 +++++++++++++++++++++--- docs/cli/setup.md | 30 ++--- docs/concepts/agent-workspace.md | 2 +- docs/concepts/model-providers.md | 32 ++--- docs/concepts/models.md | 2 +- docs/concepts/oauth.md | 4 +- docs/gateway/authentication.md | 2 +- docs/gateway/configuration-reference.md | 12 +- docs/gateway/configuration.md | 4 +- docs/gateway/local-models.md | 2 +- docs/gateway/multiple-gateways.md | 2 +- docs/help/faq.md | 12 +- docs/index.md | 4 +- docs/install/exe-dev.md | 2 +- docs/install/index.md | 6 +- docs/install/installer.md | 2 +- docs/install/macos-vm.md | 2 +- docs/platforms/digitalocean.md | 2 +- docs/platforms/index.md | 2 +- docs/platforms/linux.md | 4 +- docs/platforms/raspberry-pi.md | 2 +- docs/platforms/windows.md | 10 +- docs/providers/anthropic.md | 6 +- docs/providers/cloudflare-ai-gateway.md | 4 +- docs/providers/glm.md | 8 +- docs/providers/huggingface.md | 4 +- docs/providers/index.md | 2 +- docs/providers/kilocode.md | 2 +- docs/providers/litellm.md | 2 +- docs/providers/minimax.md | 2 +- docs/providers/mistral.md | 4 +- docs/providers/models.md | 2 +- docs/providers/moonshot.md | 4 +- docs/providers/nvidia.md | 2 +- docs/providers/ollama.md | 8 +- docs/providers/openai.md | 6 +- docs/providers/opencode-go.md | 4 +- docs/providers/opencode.md | 8 +- docs/providers/openrouter.md | 2 +- docs/providers/qianfan.md | 2 +- docs/providers/sglang.md | 2 +- docs/providers/synthetic.md | 2 +- docs/providers/together.md | 4 +- docs/providers/venice.md | 4 +- docs/providers/vercel-ai-gateway.md | 4 +- docs/providers/xiaomi.md | 4 +- docs/providers/zai.md | 8 +- docs/reference/wizard.md | 14 +-- docs/start/getting-started.md | 2 +- docs/start/onboarding-overview.md | 20 +-- docs/start/wizard-cli-automation.md | 34 ++--- docs/start/wizard-cli-reference.md | 26 ++-- docs/start/wizard.md | 10 +- docs/tools/plugin.md | 8 +- src/cli/program/register.setup.ts | 6 +- src/commands/onboard.ts | 2 +- src/commands/reset.ts | 4 +- 64 files changed, 328 insertions(+), 219 deletions(-) diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index bc1892d1e9a..36e44b6d909 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -55,10 +55,6 @@ "source": "CLI Setup Reference", "target": "CLI 设置参考" }, - { - "source": "Setup Overview", - "target": "设置概览" - }, { "source": "Setup Wizard (CLI)", "target": "设置向导(CLI)" diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 84c7a234e11..deda79d3db5 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -74,7 +74,7 @@ openclaw hooks info session-memory ### Onboarding -During onboarding (`openclaw setup --wizard`), you'll be prompted to enable recommended hooks. The wizard automatically discovers eligible hooks and presents them for selection. +During onboarding (`openclaw onboard`), you'll be prompted to enable recommended hooks. The wizard automatically discovers eligible hooks and presents them for selection. ## Hook Discovery diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index c51c7967b00..9c2f0eb6de4 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -26,7 +26,7 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R 1. Install the BlueBubbles server on your Mac (follow the instructions at [bluebubbles.app/install](https://bluebubbles.app/install)). 2. In the BlueBubbles config, enable the web API and set a password. -3. Run `openclaw setup --wizard` and select BlueBubbles, or configure manually: +3. Run `openclaw onboard` and select BlueBubbles, or configure manually: ```json5 { @@ -129,7 +129,7 @@ launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist BlueBubbles is available in the interactive setup wizard: ``` -openclaw setup --wizard +openclaw onboard ``` The wizard prompts for: diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 7e13a3077df..3768906d940 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -35,7 +35,7 @@ There are two ways to add the Feishu channel: If you just installed OpenClaw, run the setup wizard: ```bash -openclaw setup --wizard +openclaw onboard ``` The wizard guides you through: diff --git a/docs/channels/nostr.md b/docs/channels/nostr.md index ce410dd879a..46888da0352 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 setup --wizard`) and `openclaw channels add` list optional channel plugins. +- The setup wizard (`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/synology-chat.md b/docs/channels/synology-chat.md index cc3b2f2ed73..aae655f27b7 100644 --- a/docs/channels/synology-chat.md +++ b/docs/channels/synology-chat.md @@ -27,7 +27,7 @@ Details: [Plugins](/tools/plugin) ## Quick setup 1. Install and enable the Synology Chat plugin. - - `openclaw setup --wizard` now shows Synology Chat in the same channel setup list as `openclaw channels add`. + - `openclaw onboard` now shows Synology Chat in the same channel setup list as `openclaw channels add`. - Non-interactive setup: `openclaw channels add --channel synology-chat --token --url ` 2. In Synology Chat integrations: - Create an incoming webhook and copy its URL. @@ -36,7 +36,7 @@ Details: [Plugins](/tools/plugin) - `https://gateway-host/webhook/synology` by default. - Or your custom `channels.synology-chat.webhookPath`. 4. Finish setup in OpenClaw. - - Guided: `openclaw setup --wizard` + - Guided: `openclaw onboard` - Direct: `openclaw channels add --channel synology-chat --token --url ` 5. Restart gateway and send a DM to the Synology Chat bot. diff --git a/docs/cli/index.md b/docs/cli/index.md index 80e6efdadd5..ded970cde9d 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -13,7 +13,7 @@ This page describes the current CLI behavior. If commands change, update this do ## Command pages - [`setup`](/cli/setup) -- [`onboard`](/cli/onboard) (legacy alias for `setup --wizard`) +- [`onboard`](/cli/onboard) - [`configure`](/cli/configure) - [`config`](/cli/config) - [`completion`](/cli/completion) diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 16aa8413135..899ccd82713 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -1,30 +1,157 @@ --- -summary: "Legacy CLI alias for `openclaw setup --wizard`" +summary: "CLI reference for `openclaw onboard` (interactive setup wizard)" read_when: - - You encountered `openclaw onboard` in older docs or scripts + - You want guided setup for gateway, workspace, auth, channels, and skills title: "onboard" --- # `openclaw onboard` -Legacy alias for `openclaw setup --wizard`. - -Prefer: - -```bash -openclaw setup --wizard -``` - -`openclaw onboard` still accepts the same flags and behavior for compatibility. +Interactive setup wizard (local or remote Gateway setup). ## Related guides -- Primary command docs: [`openclaw setup`](/cli/setup) -- Setup wizard guide: [Setup Wizard (CLI)](/start/wizard) -- Setup overview: [Setup Overview](/start/onboarding-overview) -- Setup wizard reference: [CLI Setup Reference](/start/wizard-cli-reference) +- CLI onboarding hub: [Setup Wizard (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) - macOS onboarding: [Onboarding (macOS App)](/start/onboarding) -For examples, flags, and non-interactive behavior, use the primary docs at -[`openclaw setup`](/cli/setup) and [CLI Setup Reference](/start/wizard-cli-reference). +## Examples + +```bash +openclaw onboard +openclaw onboard --flow quickstart +openclaw onboard --flow manual +openclaw onboard --mode remote --remote-url wss://gateway-host:18789 +``` + +For plaintext private-network `ws://` targets (trusted networks only), set +`OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` in the onboarding process environment. + +Non-interactive custom provider: + +```bash +openclaw onboard --non-interactive \ + --auth-choice custom-api-key \ + --custom-base-url "https://llm.example.com/v1" \ + --custom-model-id "foo-large" \ + --custom-api-key "$CUSTOM_API_KEY" \ + --secret-input-mode plaintext \ + --custom-compatibility openai +``` + +`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`. + +Non-interactive Ollama: + +```bash +openclaw onboard --non-interactive \ + --auth-choice ollama \ + --custom-base-url "http://ollama-host:11434" \ + --custom-model-id "qwen3.5:27b" \ + --accept-risk +``` + +`--custom-base-url` defaults to `http://127.0.0.1:11434`. `--custom-model-id` is optional; if omitted, onboarding uses Ollama's suggested defaults. Cloud model IDs such as `kimi-k2.5:cloud` also work here. + +Store provider keys as refs instead of plaintext: + +```bash +openclaw onboard --non-interactive \ + --auth-choice openai-api-key \ + --secret-input-mode ref \ + --accept-risk +``` + +With `--secret-input-mode ref`, onboarding writes env-backed refs instead of plaintext key values. +For auth-profile backed providers this writes `keyRef` entries; for custom providers this writes `models.providers..apiKey` as an env ref (for example `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`). + +Non-interactive `ref` mode contract: + +- Set the provider env var in the onboarding process environment (for example `OPENAI_API_KEY`). +- Do not pass inline key flags (for example `--openai-api-key`) unless that env var is also set. +- If an inline key flag is passed without the required env var, onboarding fails fast with guidance. + +Gateway token options in non-interactive mode: + +- `--gateway-auth token --gateway-token ` stores a plaintext token. +- `--gateway-auth token --gateway-token-ref-env ` stores `gateway.auth.token` as an env SecretRef. +- `--gateway-token` and `--gateway-token-ref-env` are mutually exclusive. +- `--gateway-token-ref-env` requires a non-empty env var in the onboarding process environment. +- With `--install-daemon`, when token auth requires a token, SecretRef-managed gateway tokens are validated but not persisted as resolved plaintext in supervisor service environment metadata. +- With `--install-daemon`, if token mode requires a token and the configured token SecretRef is unresolved, onboarding fails closed with remediation guidance. +- With `--install-daemon`, if both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, onboarding blocks install until mode is set explicitly. + +Example: + +```bash +export OPENCLAW_GATEWAY_TOKEN="your-token" +openclaw onboard --non-interactive \ + --mode local \ + --auth-choice skip \ + --gateway-auth token \ + --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \ + --accept-risk +``` + +Non-interactive local gateway health: + +- Unless you pass `--skip-health`, onboarding waits for a reachable local gateway before it exits successfully. +- `--install-daemon` starts the managed gateway install path first. Without it, you must already have a local gateway running, for example `openclaw gateway run`. +- If you only want config/workspace/bootstrap writes in automation, use `--skip-health`. +- On native Windows, `--install-daemon` tries Scheduled Tasks first and falls back to a per-user Startup-folder login item if task creation is denied. + +Interactive onboarding behavior with reference mode: + +- Choose **Use secret reference** when prompted. +- Then choose either: + - Environment variable + - Configured secret provider (`file` or `exec`) +- Onboarding performs a fast preflight validation before saving the ref. + - If validation fails, onboarding shows the error and lets you retry. + +Non-interactive Z.AI endpoint choices: + +Note: `--auth-choice zai-api-key` now auto-detects the best Z.AI endpoint for your key (prefers the general API with `zai/glm-5`). +If you specifically want the GLM Coding Plan endpoints, pick `zai-coding-global` or `zai-coding-cn`. + +```bash +# Promptless endpoint selection +openclaw onboard --non-interactive \ + --auth-choice zai-coding-global \ + --zai-api-key "$ZAI_API_KEY" + +# Other Z.AI endpoint choices: +# --auth-choice zai-coding-cn +# --auth-choice zai-global +# --auth-choice zai-cn +``` + +Non-interactive Mistral example: + +```bash +openclaw onboard --non-interactive \ + --auth-choice mistral-api-key \ + --mistral-api-key "$MISTRAL_API_KEY" +``` + +Flow notes: + +- `quickstart`: minimal prompts, auto-generates a gateway token. +- `manual`: full prompts for port/bind/auth (alias of `advanced`). +- Local onboarding DM scope behavior: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals). +- Fastest first chat: `openclaw dashboard` (Control UI, no channel setup). +- Custom Provider: connect any OpenAI or Anthropic compatible endpoint, + including hosted providers not listed. Use Unknown to auto-detect. + +## Common follow-up commands + +```bash +openclaw configure +openclaw agents add +``` + + +`--json` does not imply non-interactive mode. Use `--non-interactive` for scripts. + diff --git a/docs/cli/setup.md b/docs/cli/setup.md index d8b5f686ef9..d8992ba8a43 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -1,43 +1,29 @@ --- -summary: "CLI reference for `openclaw setup` (initialize config/workspace or run the setup wizard)" +summary: "CLI reference for `openclaw setup` (initialize config + workspace)" read_when: - - You want first-run setup without the guided wizard - - You want the guided setup wizard via `openclaw setup --wizard` + - You’re doing first-run setup without the full setup wizard - You want to set the default workspace path title: "setup" --- # `openclaw setup` -Initialize `~/.openclaw/openclaw.json` and the agent workspace, or run the guided setup wizard. +Initialize `~/.openclaw/openclaw.json` and the agent workspace. Related: - Getting started: [Getting started](/start/getting-started) -- Setup wizard: [Setup Wizard (CLI)](/start/wizard) -- macOS app onboarding: [Onboarding](/start/onboarding) +- Wizard: [Onboarding](/start/onboarding) ## Examples ```bash openclaw setup openclaw setup --workspace ~/.openclaw/workspace -openclaw setup --wizard -openclaw setup --wizard --install-daemon ``` -Without flags, `openclaw setup` only ensures config + workspace defaults. -Use `--wizard` for the full guided flow. +To run the wizard via setup: -## Modes - -- `openclaw setup`: initialize config/workspace defaults only -- `openclaw setup --wizard`: guided setup for auth, gateway, channels, and skills -- `openclaw setup --wizard --non-interactive`: scripted setup flow - -## Related guides - -- Setup wizard guide: [Setup Wizard (CLI)](/start/wizard) -- Setup wizard reference: [CLI Setup Reference](/start/wizard-cli-reference) -- Setup wizard automation: [CLI Automation](/start/wizard-cli-automation) -- Legacy alias: [`openclaw onboard`](/cli/onboard) +```bash +openclaw setup --wizard +``` diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index 7fc114818cb..ff55f241bcd 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -36,7 +36,7 @@ inside a sandbox workspace under `~/.openclaw/sandboxes`, not your host workspac } ``` -`openclaw setup --wizard`, `openclaw configure`, or `openclaw setup` will create the +`openclaw onboard`, `openclaw configure`, or `openclaw setup` will create the workspace and seed the bootstrap files if they are missing. Sandbox seed copies only accept regular in-workspace files; symlink/hardlink aliases that resolve outside the source workspace are ignored. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 8e8f17f4a67..fc0656c0dd4 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -15,7 +15,7 @@ For model selection rules, see [/concepts/models](/concepts/models). - Model refs use `provider/model` (example: `opencode/claude-opus-4-6`). - If you set `agents.defaults.models`, it becomes the allowlist. -- CLI helpers: `openclaw setup --wizard`, `openclaw models list`, `openclaw models set `. +- CLI helpers: `openclaw onboard`, `openclaw models list`, `openclaw models set `. - Provider plugins can inject model catalogs via `registerProvider({ catalog })`; OpenClaw merges that output into `models.providers` before writing `models.json`. @@ -139,7 +139,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Auth: `OPENAI_API_KEY` - Optional rotation: `OPENAI_API_KEYS`, `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, plus `OPENCLAW_LIVE_OPENAI_KEY` (single override) - Example models: `openai/gpt-5.4`, `openai/gpt-5.4-pro` -- CLI: `openclaw setup --wizard --auth-choice openai-api-key` +- CLI: `openclaw onboard --auth-choice openai-api-key` - Default transport is `auto` (WebSocket-first, SSE fallback) - Override per model via `agents.defaults.models["openai/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) - OpenAI Responses WebSocket warm-up defaults to enabled via `params.openaiWsWarmup` (`true`/`false`) @@ -159,7 +159,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Auth: `ANTHROPIC_API_KEY` or `claude setup-token` - Optional rotation: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`, plus `OPENCLAW_LIVE_ANTHROPIC_KEY` (single override) - Example model: `anthropic/claude-opus-4-6` -- CLI: `openclaw setup --wizard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic` +- CLI: `openclaw onboard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic` - Direct API-key models support the shared `/fast` toggle and `params.fastMode`; OpenClaw maps that to Anthropic `service_tier` (`auto` vs `standard_only`) - Policy note: setup-token support is technical compatibility; Anthropic has blocked some subscription usage outside Claude Code in the past. Verify current Anthropic terms and decide based on your risk tolerance. - Recommendation: Anthropic API key auth is the safer, recommended path over subscription setup-token auth. @@ -175,7 +175,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Provider: `openai-codex` - Auth: OAuth (ChatGPT) - Example model: `openai-codex/gpt-5.4` -- CLI: `openclaw setup --wizard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex` +- CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex` - Default transport is `auto` (WebSocket-first, SSE fallback) - Override per model via `agents.defaults.models["openai-codex/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) - Shares the same `/fast` toggle and `params.fastMode` config as direct `openai/*` @@ -194,7 +194,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Zen runtime provider: `opencode` - Go runtime provider: `opencode-go` - Example models: `opencode/claude-opus-4-6`, `opencode-go/kimi-k2.5` -- CLI: `openclaw setup --wizard --auth-choice opencode-zen` or `openclaw setup --wizard --auth-choice opencode-go` +- CLI: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go` ```json5 { @@ -209,7 +209,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Optional rotation: `GEMINI_API_KEYS`, `GEMINI_API_KEY_1`, `GEMINI_API_KEY_2`, `GOOGLE_API_KEY` fallback, and `OPENCLAW_LIVE_GEMINI_KEY` (single override) - Example models: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview` - Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview` -- CLI: `openclaw setup --wizard --auth-choice gemini-api-key` +- CLI: `openclaw onboard --auth-choice gemini-api-key` ### Google Vertex and Gemini CLI @@ -227,7 +227,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Provider: `zai` - Auth: `ZAI_API_KEY` - Example model: `zai/glm-5` -- CLI: `openclaw setup --wizard --auth-choice zai-api-key` +- CLI: `openclaw onboard --auth-choice zai-api-key` - Aliases: `z.ai/*` and `z-ai/*` normalize to `zai/*` ### Vercel AI Gateway @@ -235,14 +235,14 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Provider: `vercel-ai-gateway` - Auth: `AI_GATEWAY_API_KEY` - Example model: `vercel-ai-gateway/anthropic/claude-opus-4.6` -- CLI: `openclaw setup --wizard --auth-choice ai-gateway-api-key` +- CLI: `openclaw onboard --auth-choice ai-gateway-api-key` ### Kilo Gateway - Provider: `kilocode` - Auth: `KILOCODE_API_KEY` - Example model: `kilocode/anthropic/claude-opus-4.6` -- CLI: `openclaw setup --wizard --kilocode-api-key ` +- CLI: `openclaw onboard --kilocode-api-key ` - Base URL: `https://api.kilo.ai/api/gateway/` - Expanded built-in catalog includes GLM-5 Free, MiniMax M2.5 Free, GPT-5.2, Gemini 3 Pro Preview, Gemini 3 Flash Preview, Grok Code Fast 1, and Kimi K2.5. @@ -271,13 +271,13 @@ See [/providers/kilocode](/providers/kilocode) for setup details. - xAI: `xai` (`XAI_API_KEY`) - Mistral: `mistral` (`MISTRAL_API_KEY`) - Example model: `mistral/mistral-large-latest` -- CLI: `openclaw setup --wizard --auth-choice mistral-api-key` +- CLI: `openclaw onboard --auth-choice mistral-api-key` - Groq: `groq` (`GROQ_API_KEY`) - Cerebras: `cerebras` (`CEREBRAS_API_KEY`) - GLM models on Cerebras use ids `zai-glm-4.7` and `zai-glm-4.6`. - OpenAI-compatible base URL: `https://api.cerebras.ai/v1`. - GitHub Copilot: `github-copilot` (`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`) -- Hugging Face Inference example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw setup --wizard --auth-choice huggingface-api-key`. See [Hugging Face (Inference)](/providers/huggingface). +- Hugging Face Inference example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw onboard --auth-choice huggingface-api-key`. See [Hugging Face (Inference)](/providers/huggingface). ## Providers via `models.providers` (custom/base URL) @@ -367,7 +367,7 @@ Volcano Engine (火山引擎) provides access to Doubao and other models in Chin - Provider: `volcengine` (coding: `volcengine-plan`) - Auth: `VOLCANO_ENGINE_API_KEY` - Example model: `volcengine/doubao-seed-1-8-251228` -- CLI: `openclaw setup --wizard --auth-choice volcengine-api-key` +- CLI: `openclaw onboard --auth-choice volcengine-api-key` ```json5 { @@ -400,7 +400,7 @@ BytePlus ARK provides access to the same models as Volcano Engine for internatio - Provider: `byteplus` (coding: `byteplus-plan`) - Auth: `BYTEPLUS_API_KEY` - Example model: `byteplus/seed-1-8-251228` -- CLI: `openclaw setup --wizard --auth-choice byteplus-api-key` +- CLI: `openclaw onboard --auth-choice byteplus-api-key` ```json5 { @@ -431,7 +431,7 @@ Synthetic provides Anthropic-compatible models behind the `synthetic` provider: - Provider: `synthetic` - Auth: `SYNTHETIC_API_KEY` - Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.5` -- CLI: `openclaw setup --wizard --auth-choice synthetic-api-key` +- CLI: `openclaw onboard --auth-choice synthetic-api-key` ```json5 { @@ -485,7 +485,7 @@ ollama pull llama3.3 Ollama is detected locally at `http://127.0.0.1:11434` when you opt in with `OLLAMA_API_KEY`, and the bundled provider plugin adds Ollama directly to -`openclaw setup --wizard` and the model picker. See [/providers/ollama](/providers/ollama) +`openclaw onboard` and the model picker. See [/providers/ollama](/providers/ollama) for onboarding, cloud/local mode, and custom configuration. ### vLLM @@ -595,7 +595,7 @@ Notes: ## CLI examples ```bash -openclaw setup --wizard --auth-choice opencode-zen +openclaw onboard --auth-choice opencode-zen openclaw models set opencode/claude-opus-4-6 openclaw models list ``` diff --git a/docs/concepts/models.md b/docs/concepts/models.md index f190630ac36..e85e605456f 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -39,7 +39,7 @@ Related: If you don’t want to hand-edit config, run the setup wizard: ```bash -openclaw setup --wizard +openclaw onboard ``` It can set up model + auth for common providers, including **OpenAI Code (Codex) diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index 4b8b2739c22..4766687ad51 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -92,7 +92,7 @@ Flow shape: 2. paste the token into OpenClaw 3. store as a token auth profile (no refresh) -The wizard path is `openclaw setup --wizard` → auth choice `setup-token` (Anthropic). +The wizard path is `openclaw onboard` → auth choice `setup-token` (Anthropic). ### OpenAI Codex (ChatGPT OAuth) @@ -107,7 +107,7 @@ Flow shape (PKCE): 5. exchange at `https://auth.openai.com/oauth/token` 6. extract `accountId` from the access token and store `{ access, refresh, expires, accountId }` -Wizard path is `openclaw setup --wizard` → auth choice `openai-codex`. +Wizard path is `openclaw onboard` → auth choice `openai-codex`. ## Refresh + expiry diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index fe8e5b760d3..c25501e6cdd 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -50,7 +50,7 @@ openclaw doctor ``` If you’d rather not manage env vars yourself, the setup wizard can store -API keys for daemon use: `openclaw setup --wizard`. +API keys for daemon use: `openclaw onboard`. See [Help](/help) for details on env inheritance (`env.shellEnv`, `~/.openclaw/.env`, systemd/launchd). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b28cde9c260..0653fd3834f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2182,7 +2182,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct. } ``` -Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw setup --wizard --auth-choice opencode-zen` or `openclaw setup --wizard --auth-choice opencode-go`. +Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`. @@ -2199,7 +2199,7 @@ Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for } ``` -Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `openclaw setup --wizard --auth-choice zai-api-key`. +Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `openclaw onboard --auth-choice zai-api-key`. - General endpoint: `https://api.z.ai/api/paas/v4` - Coding endpoint (default): `https://api.z.ai/api/coding/paas/v4` @@ -2242,7 +2242,7 @@ Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `opencl } ``` -For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw setup --wizard --auth-choice moonshot-api-key-cn`. +For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw onboard --auth-choice moonshot-api-key-cn`. @@ -2260,7 +2260,7 @@ For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw set } ``` -Anthropic-compatible, built-in provider. Shortcut: `openclaw setup --wizard --auth-choice kimi-code-api-key`. +Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choice kimi-code-api-key`. @@ -2299,7 +2299,7 @@ Anthropic-compatible, built-in provider. Shortcut: `openclaw setup --wizard --au } ``` -Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw setup --wizard --auth-choice synthetic-api-key`. +Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw onboard --auth-choice synthetic-api-key`. @@ -2339,7 +2339,7 @@ Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw se } ``` -Set `MINIMAX_API_KEY`. Shortcut: `openclaw setup --wizard --auth-choice minimax-api`. +Set `MINIMAX_API_KEY`. Shortcut: `openclaw onboard --auth-choice minimax-api`. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index db4bb167417..a699e74652f 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -20,7 +20,7 @@ If the file is missing, OpenClaw uses safe defaults. Common reasons to add a con See the [full reference](/gateway/configuration-reference) for every available field. -**New to configuration?** Start with `openclaw setup --wizard` for interactive setup, or check out the [Configuration Examples](/gateway/configuration-examples) guide for complete copy-paste configs. +**New to configuration?** Start with `openclaw onboard` for interactive setup, or check out the [Configuration Examples](/gateway/configuration-examples) guide for complete copy-paste configs. ## Minimal config @@ -38,7 +38,7 @@ See the [full reference](/gateway/configuration-reference) for every available f ```bash - openclaw setup --wizard # full setup wizard + openclaw onboard # full setup wizard openclaw configure # config wizard ``` diff --git a/docs/gateway/local-models.md b/docs/gateway/local-models.md index 93a63c38170..4059f988776 100644 --- a/docs/gateway/local-models.md +++ b/docs/gateway/local-models.md @@ -11,7 +11,7 @@ title: "Local Models" Local is doable, but OpenClaw expects large context + strong defenses against prompt injection. Small cards truncate context and leak safety. Aim high: **≥2 maxed-out Mac Studios or equivalent GPU rig (~$30k+)**. A single **24 GB** GPU works only for lighter prompts with higher latency. Use the **largest / full-size model variant you can run**; aggressively quantized or “small” checkpoints raise prompt-injection risk (see [Security](/gateway/security)). -If you want the lowest-friction local setup, start with [Ollama](/providers/ollama) and `openclaw setup --wizard`. This page is the opinionated guide for higher-end local stacks and custom OpenAI-compatible local servers. +If you want the lowest-friction local setup, start with [Ollama](/providers/ollama) and `openclaw onboard`. This page is the opinionated guide for higher-end local stacks and custom OpenAI-compatible local servers. ## Recommended: LM Studio + MiniMax M2.5 (Responses API, full-size) diff --git a/docs/gateway/multiple-gateways.md b/docs/gateway/multiple-gateways.md index b2d5257f0ff..6d1cf423b98 100644 --- a/docs/gateway/multiple-gateways.md +++ b/docs/gateway/multiple-gateways.md @@ -59,7 +59,7 @@ Port spacing: leave at least 20 ports between base ports so the derived browser/ ```bash # Main bot (existing or fresh, without --profile param) # Runs on port 18789 + Chrome CDC/Canvas/... Ports -openclaw setup --wizard +openclaw onboard openclaw gateway install # Rescue bot (isolated profile + ports) diff --git a/docs/help/faq.md b/docs/help/faq.md index 670ea170c19..8fdf39ab5c1 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -321,7 +321,7 @@ The repo recommends running from source and using the setup wizard: ```bash curl -fsSL https://openclaw.ai/install.sh | bash -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` The wizard can also build UI assets automatically. After onboarding, you typically run the Gateway on port **18789**. @@ -334,10 +334,10 @@ cd openclaw pnpm install pnpm build pnpm ui:build # auto-installs UI deps on first run -openclaw setup --wizard +openclaw onboard ``` -If you don't have a global install yet, run it via `pnpm openclaw setup --wizard`. +If you don't have a global install yet, run it via `pnpm openclaw onboard`. ### How do I open the dashboard after onboarding @@ -687,7 +687,7 @@ Docs: [Update](/cli/update), [Updating](/install/updating). ### What does the setup wizard actually do -`openclaw setup --wizard` is the recommended setup path. In **local mode** it walks you through: +`openclaw onboard` is the recommended setup path. In **local mode** it walks you through: - **Model/auth setup** (provider OAuth/setup-token flows and API keys supported, plus local model options such as LM Studio) - **Workspace** location + bootstrap files @@ -1904,7 +1904,7 @@ openclaw reset --scope full --yes --non-interactive Then re-run setup: ```bash -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` Notes: @@ -2092,7 +2092,7 @@ Quickest setup: 1. Install Ollama from `https://ollama.com/download` 2. Pull a local model such as `ollama pull glm-4.7-flash` 3. If you want Ollama Cloud too, run `ollama signin` -4. Run `openclaw setup --wizard` and choose `Ollama` +4. Run `openclaw onboard` and choose `Ollama` 5. Pick `Local` or `Cloud + Local` Notes: diff --git a/docs/index.md b/docs/index.md index e8c2210caff..7c69600f55d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,7 +34,7 @@ title: "OpenClaw" Install OpenClaw and bring up the Gateway in minutes. - Guided setup with `openclaw setup --wizard` and pairing flows. + Guided setup with `openclaw onboard` and pairing flows. Launch the browser dashboard for chat, config, and sessions. @@ -103,7 +103,7 @@ The Gateway is the single source of truth for sessions, routing, and channel con ```bash - openclaw setup --wizard --install-daemon + openclaw onboard --install-daemon ``` diff --git a/docs/install/exe-dev.md b/docs/install/exe-dev.md index b66865593da..c49dab4e426 100644 --- a/docs/install/exe-dev.md +++ b/docs/install/exe-dev.md @@ -31,7 +31,7 @@ Shelley, [exe.dev](https://exe.dev)'s agent, can install OpenClaw instantly with prompt. The prompt used is as below: ``` -Set up OpenClaw (https://docs.openclaw.ai/install) on this VM. Use the non-interactive and accept-risk flags for openclaw setup --wizarding. Add the supplied auth or token as needed. Configure nginx to forward from the default port 18789 to the root location on the default enabled site config, making sure to enable Websocket support. Pairing is done by "openclaw devices list" and "openclaw devices approve ". Make sure the dashboard shows that OpenClaw's health is OK. exe.dev handles forwarding from port 8000 to port 80/443 and HTTPS for us, so the final "reachable" should be .exe.xyz, without port specification. +Set up OpenClaw (https://docs.openclaw.ai/install) on this VM. Use the non-interactive and accept-risk flags for openclaw onboarding. Add the supplied auth or token as needed. Configure nginx to forward from the default port 18789 to the root location on the default enabled site config, making sure to enable Websocket support. Pairing is done by "openclaw devices list" and "openclaw devices approve ". Make sure the dashboard shows that OpenClaw's health is OK. exe.dev handles forwarding from port 8000 to port 80/443 and HTTPS for us, so the final "reachable" should be .exe.xyz, without port specification. ``` ## Manual installation diff --git a/docs/install/index.md b/docs/install/index.md index 59396c49b5f..21adfdaa592 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -76,7 +76,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl ```bash npm install -g openclaw@latest - openclaw setup --wizard --install-daemon + openclaw onboard --install-daemon ``` @@ -93,7 +93,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl ```bash pnpm add -g openclaw@latest pnpm approve-builds -g # approve openclaw, node-llama-cpp, sharp, etc. - openclaw setup --wizard --install-daemon + openclaw onboard --install-daemon ``` @@ -140,7 +140,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl ```bash - openclaw setup --wizard --install-daemon + openclaw onboard --install-daemon ``` diff --git a/docs/install/installer.md b/docs/install/installer.md index 813fa7b31b4..5859c22fd0d 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -224,7 +224,7 @@ Designed for environments where you want everything under a local prefix (defaul | `--version ` | OpenClaw version or dist-tag (default: `latest`) | | `--node-version ` | Node version (default: `22.22.0`) | | `--json` | Emit NDJSON events | -| `--onboard` | Run `openclaw setup --wizard` after install | +| `--onboard` | Run `openclaw onboard` after install | | `--no-onboard` | Skip onboarding (default) | | `--set-npm-prefix` | On Linux, force npm prefix to `~/.npm-global` if current prefix is not writable | | `--help` | Show usage (`-h`) | diff --git a/docs/install/macos-vm.md b/docs/install/macos-vm.md index 3e036c6ee0d..f2eadfda113 100644 --- a/docs/install/macos-vm.md +++ b/docs/install/macos-vm.md @@ -138,7 +138,7 @@ Inside the VM: ```bash npm install -g openclaw@latest -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` Follow the onboarding prompts to set up your model provider (Anthropic, OpenAI, etc.). diff --git a/docs/platforms/digitalocean.md b/docs/platforms/digitalocean.md index aaea2644ca6..cd05587ae76 100644 --- a/docs/platforms/digitalocean.md +++ b/docs/platforms/digitalocean.md @@ -80,7 +80,7 @@ openclaw --version ## 4) Run Onboarding ```bash -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` The wizard will walk you through: diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 3c7ecca0f48..ec2663aefe4 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -42,7 +42,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v Use one of these (all supported): -- Wizard (recommended): `openclaw setup --wizard --install-daemon` +- Wizard (recommended): `openclaw onboard --install-daemon` - Direct: `openclaw gateway install` - Configure flow: `openclaw configure` → select **Gateway service** - Repair/migrate: `openclaw doctor` (offers to install or fix the service) diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index 29de3dd47ea..c03dba6f795 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -17,7 +17,7 @@ Native Linux companion apps are planned. Contributions are welcome if you want t 1. Install Node 24 (recommended; Node 22 LTS, currently `22.16+`, still works for compatibility) 2. `npm i -g openclaw@latest` -3. `openclaw setup --wizard --install-daemon` +3. `openclaw onboard --install-daemon` 4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 @` 5. Open `http://127.0.0.1:18789/` and paste your token @@ -39,7 +39,7 @@ Step-by-step VPS guide: [exe.dev](/install/exe-dev) Use one of these: ``` -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` Or: diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md index 4a3bf7b8204..2050b6395b4 100644 --- a/docs/platforms/raspberry-pi.md +++ b/docs/platforms/raspberry-pi.md @@ -130,7 +130,7 @@ The hackable install gives you direct access to logs and code — useful for deb ## 7) Run Onboarding ```bash -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` Follow the wizard: diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index c8047271e65..e40d798604d 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -38,8 +38,8 @@ openclaw agent --local --agent main --thinking low -m "Reply with exactly WINDOW Current caveats: -- `openclaw setup --wizard --non-interactive` still expects a reachable local gateway unless you pass `--skip-health` -- `openclaw setup --wizard --non-interactive --install-daemon` and `openclaw gateway install` try Windows Scheduled Tasks first +- `openclaw onboard --non-interactive` still expects a reachable local gateway unless you pass `--skip-health` +- `openclaw onboard --non-interactive --install-daemon` and `openclaw gateway install` try Windows Scheduled Tasks first - if Scheduled Task creation is denied, OpenClaw falls back to a per-user Startup-folder login item and starts the gateway immediately - if `schtasks` itself wedges or stops responding, OpenClaw now aborts that path quickly and falls back instead of hanging forever - Scheduled Tasks are still preferred when available because they provide better supervisor status @@ -47,7 +47,7 @@ Current caveats: If you want the native CLI only, without gateway service install, use one of these: ```powershell -openclaw setup --wizard --non-interactive --skip-health +openclaw onboard --non-interactive --skip-health openclaw gateway run ``` @@ -70,7 +70,7 @@ If Scheduled Task creation is blocked, the fallback service mode still auto-star Inside WSL2: ``` -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` Or: @@ -230,7 +230,7 @@ cd openclaw pnpm install pnpm ui:build # auto-installs UI deps on first run pnpm build -openclaw setup --wizard +openclaw onboard ``` Full guide: [Getting Started](/start/getting-started) diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 5611eec7ba4..d16d76f6315 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -19,11 +19,11 @@ Create your API key in the Anthropic Console. ### CLI setup ```bash -openclaw setup --wizard +openclaw onboard # choose: Anthropic API key # or non-interactive -openclaw setup --wizard --anthropic-api-key "$ANTHROPIC_API_KEY" +openclaw onboard --anthropic-api-key "$ANTHROPIC_API_KEY" ``` ### Config snippet @@ -214,7 +214,7 @@ openclaw models auth paste-token --provider anthropic ```bash # Paste a setup-token during setup -openclaw setup --wizard --auth-choice setup-token +openclaw onboard --auth-choice setup-token ``` ### Config snippet (setup-token) diff --git a/docs/providers/cloudflare-ai-gateway.md b/docs/providers/cloudflare-ai-gateway.md index 63f471413e8..392a611e705 100644 --- a/docs/providers/cloudflare-ai-gateway.md +++ b/docs/providers/cloudflare-ai-gateway.md @@ -22,7 +22,7 @@ For Anthropic models, use your Anthropic API key. 1. Set the provider API key and Gateway details: ```bash -openclaw setup --wizard --auth-choice cloudflare-ai-gateway-api-key +openclaw onboard --auth-choice cloudflare-ai-gateway-api-key ``` 2. Set a default model: @@ -40,7 +40,7 @@ openclaw setup --wizard --auth-choice cloudflare-ai-gateway-api-key ## Non-interactive example ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice cloudflare-ai-gateway-api-key \ --cloudflare-ai-gateway-account-id "your-account-id" \ diff --git a/docs/providers/glm.md b/docs/providers/glm.md index bd096212cd0..64fe39a42df 100644 --- a/docs/providers/glm.md +++ b/docs/providers/glm.md @@ -15,16 +15,16 @@ models are accessed via the `zai` provider and model IDs like `zai/glm-5`. ```bash # Coding Plan Global, recommended for Coding Plan users -openclaw setup --wizard --auth-choice zai-coding-global +openclaw onboard --auth-choice zai-coding-global # Coding Plan CN (China region), recommended for Coding Plan users -openclaw setup --wizard --auth-choice zai-coding-cn +openclaw onboard --auth-choice zai-coding-cn # General API -openclaw setup --wizard --auth-choice zai-global +openclaw onboard --auth-choice zai-global # General API CN (China region) -openclaw setup --wizard --auth-choice zai-cn +openclaw onboard --auth-choice zai-cn ``` ## Config snippet diff --git a/docs/providers/huggingface.md b/docs/providers/huggingface.md index 416037dca49..7b33955f524 100644 --- a/docs/providers/huggingface.md +++ b/docs/providers/huggingface.md @@ -21,7 +21,7 @@ title: "Hugging Face (Inference)" 2. Run onboarding and choose **Hugging Face** in the provider dropdown, then enter your API key when prompted: ```bash -openclaw setup --wizard --auth-choice huggingface-api-key +openclaw onboard --auth-choice huggingface-api-key ``` 3. In the **Default Hugging Face model** dropdown, pick the model you want (the list is loaded from the Inference API when you have a valid token; otherwise a built-in list is shown). Your choice is saved as the default model. @@ -40,7 +40,7 @@ openclaw setup --wizard --auth-choice huggingface-api-key ## Non-interactive example ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice huggingface-api-key \ --huggingface-api-key "$HF_TOKEN" diff --git a/docs/providers/index.md b/docs/providers/index.md index 0e5c181f56b..f68cd0e0b53 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -15,7 +15,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi ## Quick start -1. Authenticate with the provider (usually via `openclaw setup --wizard`). +1. Authenticate with the provider (usually via `openclaw onboard`). 2. Set the default model: ```json5 diff --git a/docs/providers/kilocode.md b/docs/providers/kilocode.md index b3d75e64bcf..15f8e4c2b7c 100644 --- a/docs/providers/kilocode.md +++ b/docs/providers/kilocode.md @@ -19,7 +19,7 @@ endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switc ## CLI setup ```bash -openclaw setup --wizard --kilocode-api-key +openclaw onboard --kilocode-api-key ``` Or set the environment variable: diff --git a/docs/providers/litellm.md b/docs/providers/litellm.md index d96e1bb795c..51ad0d599f8 100644 --- a/docs/providers/litellm.md +++ b/docs/providers/litellm.md @@ -22,7 +22,7 @@ read_when: ### Via onboarding ```bash -openclaw setup --wizard --auth-choice litellm-api-key +openclaw onboard --auth-choice litellm-api-key ``` ### Manual setup diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 7a39111f6c2..0d3635352cc 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -44,7 +44,7 @@ Enable the bundled OAuth plugin and authenticate: ```bash openclaw plugins enable minimax # skip if already loaded. openclaw gateway restart # restart if gateway is already running -openclaw setup --wizard --auth-choice minimax-portal +openclaw onboard --auth-choice minimax-portal ``` You will be prompted to select an endpoint: diff --git a/docs/providers/mistral.md b/docs/providers/mistral.md index 60a9e82853d..44e594abf21 100644 --- a/docs/providers/mistral.md +++ b/docs/providers/mistral.md @@ -15,9 +15,9 @@ Mistral can also be used for memory embeddings (`memorySearch.provider = "mistra ## CLI setup ```bash -openclaw setup --wizard --auth-choice mistral-api-key +openclaw onboard --auth-choice mistral-api-key # or non-interactive -openclaw setup --wizard --mistral-api-key "$MISTRAL_API_KEY" +openclaw onboard --mistral-api-key "$MISTRAL_API_KEY" ``` ## Config snippet (LLM provider) diff --git a/docs/providers/models.md b/docs/providers/models.md index 0bbff47c51e..a117d286051 100644 --- a/docs/providers/models.md +++ b/docs/providers/models.md @@ -13,7 +13,7 @@ model as `provider/model`. ## Quick start (two steps) -1. Authenticate with the provider (usually via `openclaw setup --wizard`). +1. Authenticate with the provider (usually via `openclaw onboard`). 2. Set the default model: ```json5 diff --git a/docs/providers/moonshot.md b/docs/providers/moonshot.md index de21a6ffb0a..daf9c881de5 100644 --- a/docs/providers/moonshot.md +++ b/docs/providers/moonshot.md @@ -26,13 +26,13 @@ Current Kimi K2 model IDs: [//]: # "moonshot-kimi-k2-ids:end" ```bash -openclaw setup --wizard --auth-choice moonshot-api-key +openclaw onboard --auth-choice moonshot-api-key ``` Kimi Coding: ```bash -openclaw setup --wizard --auth-choice kimi-code-api-key +openclaw onboard --auth-choice kimi-code-api-key ``` Note: Moonshot and Kimi Coding are separate providers. Keys are not interchangeable, endpoints differ, and model refs differ (Moonshot uses `moonshot/...`, Kimi Coding uses `kimi-coding/...`). diff --git a/docs/providers/nvidia.md b/docs/providers/nvidia.md index 2708d88db96..693a51db9b3 100644 --- a/docs/providers/nvidia.md +++ b/docs/providers/nvidia.md @@ -16,7 +16,7 @@ Export the key once, then run onboarding and set an NVIDIA model: ```bash export NVIDIA_API_KEY="nvapi-..." -openclaw setup --wizard --auth-choice skip +openclaw onboard --auth-choice skip openclaw models set nvidia/nvidia/llama-3.1-nemotron-70b-instruct ``` diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index db36f90a2da..5a1eb2bd27e 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -21,7 +21,7 @@ Ollama is a local LLM runtime that makes it easy to run open-source models on yo The fastest way to set up Ollama is through the setup wizard: ```bash -openclaw setup --wizard +openclaw onboard ``` Select **Ollama** from the provider list. The wizard will: @@ -35,7 +35,7 @@ Select **Ollama** from the provider list. The wizard will: Non-interactive mode is also supported: ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --auth-choice ollama \ --accept-risk ``` @@ -43,7 +43,7 @@ openclaw setup --wizard --non-interactive \ Optionally specify a custom base URL or model: ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --auth-choice ollama \ --custom-base-url "http://ollama-host:11434" \ --custom-model-id "qwen3.5:27b" \ @@ -73,7 +73,7 @@ ollama signin 4. Run onboarding and choose `Ollama`: ```bash -openclaw setup --wizard +openclaw onboard ``` - `Local`: local models only diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 4f90d092838..a6a60f8f2ea 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -20,9 +20,9 @@ Get your API key from the OpenAI dashboard. ### CLI setup ```bash -openclaw setup --wizard --auth-choice openai-api-key +openclaw onboard --auth-choice openai-api-key # or non-interactive -openclaw setup --wizard --openai-api-key "$OPENAI_API_KEY" +openclaw onboard --openai-api-key "$OPENAI_API_KEY" ``` ### Config snippet @@ -52,7 +52,7 @@ Codex cloud requires ChatGPT sign-in, while the Codex CLI supports ChatGPT or AP ```bash # Run Codex OAuth in the wizard -openclaw setup --wizard --auth-choice openai-codex +openclaw onboard --auth-choice openai-codex # Or run OAuth directly openclaw models auth login --provider openai-codex diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md index 2d826712977..4552e916beb 100644 --- a/docs/providers/opencode-go.md +++ b/docs/providers/opencode-go.md @@ -21,9 +21,9 @@ provider id `opencode-go` so upstream per-model routing stays correct. ## CLI setup ```bash -openclaw setup --wizard --auth-choice opencode-go +openclaw onboard --auth-choice opencode-go # or non-interactive -openclaw setup --wizard --opencode-go-api-key "$OPENCODE_API_KEY" +openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY" ``` ## Config snippet diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index 98eb2cfcbe0..da44e5154c0 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -22,15 +22,15 @@ as one OpenCode setup. ### Zen catalog ```bash -openclaw setup --wizard --auth-choice opencode-zen -openclaw setup --wizard --opencode-zen-api-key "$OPENCODE_API_KEY" +openclaw onboard --auth-choice opencode-zen +openclaw onboard --opencode-zen-api-key "$OPENCODE_API_KEY" ``` ### Go catalog ```bash -openclaw setup --wizard --auth-choice opencode-go -openclaw setup --wizard --opencode-go-api-key "$OPENCODE_API_KEY" +openclaw onboard --auth-choice opencode-go +openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY" ``` ## Config snippet diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md index 4da33dbb1bc..5a9023481be 100644 --- a/docs/providers/openrouter.md +++ b/docs/providers/openrouter.md @@ -14,7 +14,7 @@ endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switc ## CLI setup ```bash -openclaw setup --wizard --auth-choice apiKey --token-provider openrouter --token "$OPENROUTER_API_KEY" +openclaw onboard --auth-choice apiKey --token-provider openrouter --token "$OPENROUTER_API_KEY" ``` ## Config snippet diff --git a/docs/providers/qianfan.md b/docs/providers/qianfan.md index 9784dcc64dd..1e80dafb26b 100644 --- a/docs/providers/qianfan.md +++ b/docs/providers/qianfan.md @@ -27,7 +27,7 @@ endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switc ## CLI setup ```bash -openclaw setup --wizard --auth-choice qianfan-api-key +openclaw onboard --auth-choice qianfan-api-key ``` ## Related Documentation diff --git a/docs/providers/sglang.md b/docs/providers/sglang.md index 96d33d5e767..ce66950c0c3 100644 --- a/docs/providers/sglang.md +++ b/docs/providers/sglang.md @@ -33,7 +33,7 @@ export SGLANG_API_KEY="sglang-local" 3. Run onboarding and choose `SGLang`, or set a model directly: ```bash -openclaw setup --wizard +openclaw onboard ``` ```json5 diff --git a/docs/providers/synthetic.md b/docs/providers/synthetic.md index 0e662320984..ae406a0e390 100644 --- a/docs/providers/synthetic.md +++ b/docs/providers/synthetic.md @@ -17,7 +17,7 @@ Synthetic exposes Anthropic-compatible endpoints. OpenClaw registers it as the 2. Run onboarding: ```bash -openclaw setup --wizard --auth-choice synthetic-api-key +openclaw onboard --auth-choice synthetic-api-key ``` The default model is set to: diff --git a/docs/providers/together.md b/docs/providers/together.md index e93224e5da3..62bab43a204 100644 --- a/docs/providers/together.md +++ b/docs/providers/together.md @@ -18,7 +18,7 @@ The [Together AI](https://together.ai) provides access to leading open-source mo 1. Set the API key (recommended: store it for the Gateway): ```bash -openclaw setup --wizard --auth-choice together-api-key +openclaw onboard --auth-choice together-api-key ``` 2. Set a default model: @@ -36,7 +36,7 @@ openclaw setup --wizard --auth-choice together-api-key ## Non-interactive example ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice together-api-key \ --together-api-key "$TOGETHER_API_KEY" diff --git a/docs/providers/venice.md b/docs/providers/venice.md index a793239eb6f..520cf22d82b 100644 --- a/docs/providers/venice.md +++ b/docs/providers/venice.md @@ -58,7 +58,7 @@ export VENICE_API_KEY="vapi_xxxxxxxxxxxx" **Option B: Interactive Setup (Recommended)** ```bash -openclaw setup --wizard --auth-choice venice-api-key +openclaw onboard --auth-choice venice-api-key ``` This will: @@ -71,7 +71,7 @@ This will: **Option C: Non-interactive** ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --auth-choice venice-api-key \ --venice-api-key "vapi_xxxxxxxxxxxx" ``` diff --git a/docs/providers/vercel-ai-gateway.md b/docs/providers/vercel-ai-gateway.md index 55acf7f2ba7..f76e2b51bb5 100644 --- a/docs/providers/vercel-ai-gateway.md +++ b/docs/providers/vercel-ai-gateway.md @@ -21,7 +21,7 @@ The [Vercel AI Gateway](https://vercel.com/ai-gateway) provides a unified API to 1. Set the API key (recommended: store it for the Gateway): ```bash -openclaw setup --wizard --auth-choice ai-gateway-api-key +openclaw onboard --auth-choice ai-gateway-api-key ``` 2. Set a default model: @@ -39,7 +39,7 @@ openclaw setup --wizard --auth-choice ai-gateway-api-key ## Non-interactive example ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice ai-gateway-api-key \ --ai-gateway-api-key "$AI_GATEWAY_API_KEY" diff --git a/docs/providers/xiaomi.md b/docs/providers/xiaomi.md index ec6ec043125..da1cf7fe38a 100644 --- a/docs/providers/xiaomi.md +++ b/docs/providers/xiaomi.md @@ -22,9 +22,9 @@ the `xiaomi` provider with a Xiaomi MiMo API key. ## CLI setup ```bash -openclaw setup --wizard --auth-choice xiaomi-api-key +openclaw onboard --auth-choice xiaomi-api-key # or non-interactive -openclaw setup --wizard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY" +openclaw onboard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY" ``` ## Config snippet diff --git a/docs/providers/zai.md b/docs/providers/zai.md index 86a0b3c6878..6f3aea27020 100644 --- a/docs/providers/zai.md +++ b/docs/providers/zai.md @@ -16,16 +16,16 @@ with a Z.AI API key. ```bash # Coding Plan Global, recommended for Coding Plan users -openclaw setup --wizard --auth-choice zai-coding-global +openclaw onboard --auth-choice zai-coding-global # Coding Plan CN (China region), recommended for Coding Plan users -openclaw setup --wizard --auth-choice zai-coding-cn +openclaw onboard --auth-choice zai-coding-cn # General API -openclaw setup --wizard --auth-choice zai-global +openclaw onboard --auth-choice zai-global # General API CN (China region) -openclaw setup --wizard --auth-choice zai-cn +openclaw onboard --auth-choice zai-cn ``` ## Config snippet diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index b52aa74086d..5bfa3da7f9f 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -2,7 +2,7 @@ summary: "Full reference for the CLI setup wizard: every step, flag, and config field" read_when: - Looking up a specific wizard step or flag - - Automating setup with non-interactive mode + - Automating onboarding with non-interactive mode - Debugging wizard behavior title: "Setup Wizard Reference" sidebarTitle: "Wizard Reference" @@ -10,7 +10,7 @@ sidebarTitle: "Wizard Reference" # Setup Wizard Reference -This is the full reference for the `openclaw setup --wizard` CLI wizard. +This is the full reference for the `openclaw onboard` CLI wizard. For a high-level overview, see [Setup Wizard](/start/wizard). ## Flow details (local mode) @@ -76,11 +76,11 @@ For a high-level overview, see [Setup Wizard](/start/wizard). - In token mode, interactive setup offers: - **Generate/store plaintext token** (default) - **Use SecretRef** (opt-in) - - Quickstart reuses existing `gateway.auth.token` SecretRefs across `env`, `file`, and `exec` providers for setup probe/dashboard bootstrap. - - If that SecretRef is configured but cannot be resolved, setup fails early with a clear fix message instead of silently degrading runtime auth. + - Quickstart reuses existing `gateway.auth.token` SecretRefs across `env`, `file`, and `exec` providers for onboarding probe/dashboard bootstrap. + - If that SecretRef is configured but cannot be resolved, onboarding fails early with a clear fix message instead of silently degrading runtime auth. - In password mode, interactive setup also supports plaintext or SecretRef storage. - Non-interactive token SecretRef path: `--gateway-token-ref-env `. - - Requires a non-empty env var in the setup process environment. + - Requires a non-empty env var in the onboarding process environment. - Cannot be combined with `--gateway-token`. - Disable auth only if you fully trust every local process. - Non‑loopback binds still require auth. @@ -137,7 +137,7 @@ If the Control UI assets are missing, the wizard attempts to build them; fallbac Use `--non-interactive` to automate or script onboarding: ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice apiKey \ --anthropic-api-key "$ANTHROPIC_API_KEY" \ @@ -154,7 +154,7 @@ Gateway token SecretRef in non-interactive mode: ```bash export OPENCLAW_GATEWAY_TOKEN="your-token" -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice skip \ --gateway-auth token \ diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index af779afbe42..3fc64e5087d 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -54,7 +54,7 @@ Check your Node version with `node --version` if you are unsure. ```bash - openclaw setup --wizard --install-daemon + openclaw onboard --install-daemon ``` The wizard configures auth, gateway settings, and optional channels. diff --git a/docs/start/onboarding-overview.md b/docs/start/onboarding-overview.md index c2147252d2b..1e94a4db64a 100644 --- a/docs/start/onboarding-overview.md +++ b/docs/start/onboarding-overview.md @@ -1,18 +1,18 @@ --- -summary: "Overview of OpenClaw setup options and flows" +summary: "Overview of OpenClaw onboarding options and flows" read_when: - - Choosing a setup path + - Choosing an onboarding path - Setting up a new environment -title: "Setup Overview" -sidebarTitle: "Setup Overview" +title: "Onboarding Overview" +sidebarTitle: "Onboarding Overview" --- -# Setup Overview +# Onboarding Overview -OpenClaw supports multiple setup paths depending on where the Gateway runs +OpenClaw supports multiple onboarding paths depending on where the Gateway runs and how you prefer to configure providers. -## Choose your setup path +## Choose your onboarding path - **CLI wizard** for macOS, Linux, and Windows (via WSL2). - **macOS app** for a guided first run on Apple silicon or Intel Macs. @@ -22,14 +22,14 @@ and how you prefer to configure providers. Run the wizard in a terminal: ```bash -openclaw setup --wizard +openclaw onboard ``` Use the CLI wizard when you want full control of the Gateway, workspace, channels, and skills. Docs: - [Setup Wizard (CLI)](/start/wizard) -- [`openclaw setup --wizard` command](/cli/setup) +- [`openclaw onboard` command](/cli/onboard) ## macOS app onboarding @@ -48,4 +48,4 @@ CLI wizard. You will be asked to: - Provide a model ID and optional alias. - Choose an Endpoint ID so multiple custom endpoints can coexist. -For detailed steps, follow the CLI setup docs above. +For detailed steps, follow the CLI onboarding docs above. diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index 17803cefe48..884d49e143b 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -1,7 +1,7 @@ --- -summary: "Scripted setup wizard and agent setup for the OpenClaw CLI" +summary: "Scripted onboarding and agent setup for the OpenClaw CLI" read_when: - - You are automating setup in scripts or CI + - You are automating onboarding in scripts or CI - You need non-interactive examples for specific providers title: "CLI Automation" sidebarTitle: "CLI automation" @@ -9,7 +9,7 @@ sidebarTitle: "CLI automation" # CLI Automation -Use `--non-interactive` to automate `openclaw setup --wizard`. +Use `--non-interactive` to automate `openclaw onboard`. `--json` does not imply non-interactive mode. Use `--non-interactive` (and `--workspace`) for scripts. @@ -18,7 +18,7 @@ Use `--non-interactive` to automate `openclaw setup --wizard`. ## Baseline non-interactive example ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice apiKey \ --anthropic-api-key "$ANTHROPIC_API_KEY" \ @@ -41,7 +41,7 @@ Passing inline key flags without the matching env var now fails fast. Example: ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice openai-api-key \ --secret-input-mode ref \ @@ -53,7 +53,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice gemini-api-key \ --gemini-api-key "$GEMINI_API_KEY" \ @@ -63,7 +63,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice zai-api-key \ --zai-api-key "$ZAI_API_KEY" \ @@ -73,7 +73,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice ai-gateway-api-key \ --ai-gateway-api-key "$AI_GATEWAY_API_KEY" \ @@ -83,7 +83,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice cloudflare-ai-gateway-api-key \ --cloudflare-ai-gateway-account-id "your-account-id" \ @@ -95,7 +95,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice moonshot-api-key \ --moonshot-api-key "$MOONSHOT_API_KEY" \ @@ -105,7 +105,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice mistral-api-key \ --mistral-api-key "$MISTRAL_API_KEY" \ @@ -115,7 +115,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice synthetic-api-key \ --synthetic-api-key "$SYNTHETIC_API_KEY" \ @@ -125,7 +125,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice opencode-zen \ --opencode-zen-api-key "$OPENCODE_API_KEY" \ @@ -136,7 +136,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice ollama \ --custom-model-id "qwen3.5:27b" \ @@ -147,7 +147,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice custom-api-key \ --custom-base-url "https://llm.example.com/v1" \ @@ -165,7 +165,7 @@ openclaw setup --wizard --non-interactive \ ```bash export CUSTOM_API_KEY="your-key" - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice custom-api-key \ --custom-base-url "https://llm.example.com/v1" \ @@ -212,4 +212,4 @@ Notes: - Onboarding hub: [Setup Wizard (CLI)](/start/wizard) - Full reference: [CLI Setup Reference](/start/wizard-cli-reference) -- Command reference: [`openclaw setup --wizard`](/cli/setup) +- Command reference: [`openclaw onboard`](/cli/onboard) diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 2a2bac76528..36bd836a13f 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -1,15 +1,15 @@ --- summary: "Complete reference for CLI setup flow, auth/model setup, outputs, and internals" read_when: - - You need detailed behavior for `openclaw setup --wizard` - - You are debugging setup results or integrating setup clients + - You need detailed behavior for openclaw onboard + - You are debugging onboarding results or integrating onboarding clients title: "CLI Setup Reference" sidebarTitle: "CLI reference" --- # CLI Setup Reference -This page is the full reference for `openclaw setup --wizard`. +This page is the full reference for `openclaw onboard`. For the short guide, see [Setup Wizard (CLI)](/start/wizard). ## What the wizard does @@ -56,7 +56,7 @@ It does not install or modify anything on the remote host. - **Use SecretRef** (opt-in) - In password mode, interactive setup also supports plaintext or SecretRef storage. - Non-interactive token SecretRef path: `--gateway-token-ref-env `. - - Requires a non-empty env var in the setup process environment. + - Requires a non-empty env var in the onboarding process environment. - Cannot be combined with `--gateway-token`. - Disable auth only if you fully trust every local process. - Non-loopback binds still require auth. @@ -220,20 +220,20 @@ Credential and profile paths: Credential storage mode: -- Default setup behavior persists API keys as plaintext values in auth profiles. +- Default onboarding behavior persists API keys as plaintext values in auth profiles. - `--secret-input-mode ref` enables reference mode instead of plaintext key storage. In interactive setup, you can choose either: - environment variable ref (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`) - configured provider ref (`file` or `exec`) with provider alias + id - Interactive reference mode runs a fast preflight validation before saving. - - Env refs: validates variable name + non-empty value in the current setup environment. + - Env refs: validates variable name + non-empty value in the current onboarding environment. - Provider refs: validates provider config and resolves the requested id. - - If preflight fails, setup shows the error and lets you retry. + - If preflight fails, onboarding shows the error and lets you retry. - In non-interactive mode, `--secret-input-mode ref` is env-backed only. - - Set the provider env var in the setup process environment. - - Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise setup fails fast. + - Set the provider env var in the onboarding process environment. + - Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast. - For custom providers, non-interactive `ref` mode stores `models.providers..apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`. - - In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise setup fails fast. + - In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise onboarding fails fast. - Gateway auth credentials support plaintext and SecretRef choices in interactive setup: - Token mode: **Generate/store plaintext token** (default) or **Use SecretRef**. - Password mode: plaintext or SecretRef. @@ -252,9 +252,9 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) -- `tools.profile` (local setup defaults to `"coding"` when unset; existing explicit values are preserved) +- `tools.profile` (local onboarding defaults to `"coding"` when unset; existing explicit values are preserved) - `gateway.*` (mode, bind, auth, tailscale) -- `session.dmScope` (local setup defaults this to `per-channel-peer` when unset; existing explicit values are preserved) +- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` - Channel allowlists (Slack, Discord, Matrix, Microsoft Teams) when you opt in during prompts (names resolve to IDs when possible) - `skills.install.nodeManager` @@ -296,4 +296,4 @@ Signal setup behavior: - Onboarding hub: [Setup Wizard (CLI)](/start/wizard) - Automation and scripts: [CLI Automation](/start/wizard-cli-automation) -- Command reference: [`openclaw setup --wizard`](/cli/setup) +- Command reference: [`openclaw onboard`](/cli/onboard) diff --git a/docs/start/wizard.md b/docs/start/wizard.md index fe887ea9a4f..7bbe9df64cf 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -4,7 +4,7 @@ read_when: - Running or configuring the setup wizard - Setting up a new machine title: "Setup Wizard (CLI)" -sidebarTitle: "Setup: CLI" +sidebarTitle: "Onboarding: CLI" --- # Setup Wizard (CLI) @@ -15,7 +15,7 @@ It configures a local Gateway or a remote Gateway connection, plus channels, ski and workspace defaults in one guided flow. ```bash -openclaw setup --wizard +openclaw onboard ``` @@ -52,7 +52,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). - Gateway port **18789** - Gateway auth **Token** (auto‑generated, even on loopback) - Tool policy default for new local setups: `tools.profile: "coding"` (existing explicit profile is preserved) - - DM isolation default: local setup writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals) + - DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals) - Tailscale exposure **Off** - Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number) @@ -119,7 +119,7 @@ For the deeper technical reference, including RPC details, see ## Related docs -- CLI command reference: [`openclaw setup`](/cli/setup) -- Setup overview: [Setup Overview](/start/onboarding-overview) +- CLI command reference: [`openclaw onboard`](/cli/onboard) +- Onboarding overview: [Onboarding Overview](/start/onboarding-overview) - macOS app onboarding: [Onboarding](/start/onboarding) - Agent first-run ritual: [Agent Bootstrapping](/start/bootstrapping) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 560d25930d5..c14f3c39f56 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1195,11 +1195,11 @@ A provider plugin can participate in five distinct phases: `auth[].run(ctx)` performs OAuth, API-key capture, device code, or custom setup and returns auth profiles plus optional config patches. 2. **Non-interactive setup** - `auth[].runNonInteractive(ctx)` handles `openclaw setup --wizard --non-interactive` + `auth[].runNonInteractive(ctx)` handles `openclaw onboard --non-interactive` without prompts. Use this when the provider needs custom headless setup beyond the built-in simple API-key paths. 3. **Wizard integration** - `wizard.setup` adds an entry to `openclaw setup --wizard`. + `wizard.setup` adds an entry to `openclaw onboard`. `wizard.modelPicker` adds a setup entry to the model picker. 4. **Implicit discovery** `discovery.run(ctx)` can contribute provider config automatically during @@ -1360,7 +1360,7 @@ or more auth methods (OAuth, API key, device code, etc.). Those methods can power: - `openclaw models auth login --provider [--method ]` -- `openclaw setup --wizard` +- `openclaw onboard` - model-picker “custom provider” setup entries - implicit provider discovery during model resolution/listing @@ -1435,7 +1435,7 @@ Notes: for headless onboarding. - Return `configPatch` when you need to add default models or provider config. - Return `defaultModel` so `--set-default` can update agent defaults. -- `wizard.setup` adds a provider choice to `openclaw setup --wizard`. +- `wizard.setup` adds a provider choice to `openclaw onboard`. - `wizard.modelPicker` adds a “setup this provider” entry to the model picker. - `discovery.run` returns either `{ provider }` for the plugin’s own provider id or `{ providers }` for multi-provider discovery. diff --git a/src/cli/program/register.setup.ts b/src/cli/program/register.setup.ts index 95888cb236a..33893d945bb 100644 --- a/src/cli/program/register.setup.ts +++ b/src/cli/program/register.setup.ts @@ -10,7 +10,7 @@ import { hasExplicitOptions } from "../command-options.js"; export function registerSetupCommand(program: Command) { program .command("setup") - .description("Initialize config/workspace or run the setup wizard") + .description("Initialize ~/.openclaw/openclaw.json and the agent workspace") .addHelpText( "after", () => @@ -20,8 +20,8 @@ export function registerSetupCommand(program: Command) { "--workspace ", "Agent workspace directory (default: ~/.openclaw/workspace; stored as agents.defaults.workspace)", ) - .option("--wizard", "Run the guided setup wizard", false) - .option("--non-interactive", "Run the setup wizard without prompts", false) + .option("--wizard", "Run the interactive onboarding wizard", false) + .option("--non-interactive", "Run the wizard without prompts", false) .option("--mode ", "Wizard mode: local|remote") .option("--remote-url ", "Remote Gateway WebSocket URL") .option("--remote-token ", "Remote Gateway token (optional)") diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 8eb16fb2c07..c9af3fbf937 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -61,7 +61,7 @@ export async function setupWizardCommand( [ "Non-interactive setup requires explicit risk acknowledgement.", "Read: https://docs.openclaw.ai/security", - `Re-run with: ${formatCliCommand("openclaw setup --wizard --non-interactive --accept-risk ...")}`, + `Re-run with: ${formatCliCommand("openclaw onboard --non-interactive --accept-risk ...")}`, ].join("\n"), ); runtime.exit(1); diff --git a/src/commands/reset.ts b/src/commands/reset.ts index eca1d78e7c1..596d80a139a 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -134,7 +134,7 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) { for (const dir of sessionDirs) { await removePath(dir, runtime, { dryRun, label: dir }); } - runtime.log(`Next: ${formatCliCommand("openclaw setup --wizard --install-daemon")}`); + runtime.log(`Next: ${formatCliCommand("openclaw onboard --install-daemon")}`); return; } @@ -145,7 +145,7 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) { { dryRun }, ); await removeWorkspaceDirs(workspaceDirs, runtime, { dryRun }); - runtime.log(`Next: ${formatCliCommand("openclaw setup --wizard --install-daemon")}`); + runtime.log(`Next: ${formatCliCommand("openclaw onboard --install-daemon")}`); return; } } From 2580b81bd217702c9302072e6a70de9b90f64b9b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 22:52:56 -0700 Subject: [PATCH 015/133] refactor: move channel capability diagnostics into plugins --- docs/refactor/plugin-sdk.md | 2 + extensions/discord/src/channel.ts | 104 +++++ extensions/msteams/src/channel.ts | 45 +++ extensions/signal/src/channel.ts | 5 + extensions/slack/src/channel.ts | 58 +++ extensions/telegram/src/channel.ts | 27 +- src/auto-reply/reply/route-reply.test.ts | 7 +- src/auto-reply/reply/route-reply.ts | 29 +- .../plugins/message-actions.security.test.ts | 2 + src/channels/plugins/message-actions.ts | 18 +- src/channels/plugins/types.adapters.ts | 29 ++ src/channels/plugins/types.core.ts | 2 + src/channels/plugins/types.ts | 3 + src/channels/reply-prefix.ts | 7 +- src/commands/channels/add.ts | 16 - src/commands/channels/capabilities.test.ts | 36 +- src/commands/channels/capabilities.ts | 360 +++--------------- src/commands/channels/remove.ts | 5 - 18 files changed, 363 insertions(+), 392 deletions(-) diff --git a/docs/refactor/plugin-sdk.md b/docs/refactor/plugin-sdk.md index 05d519a0d24..5a630982a97 100644 --- a/docs/refactor/plugin-sdk.md +++ b/docs/refactor/plugin-sdk.md @@ -223,6 +223,8 @@ channel-specific UX and routing behavior: - `messaging.enableInteractiveReplies`: channel-owned reply normalization toggles (for example Slack interactive replies) - `messaging.resolveOutboundSessionRoute`: channel-owned outbound session routing +- `status.formatCapabilitiesProbe` / `status.buildCapabilitiesDiagnostics`: channel-owned + `/channels capabilities` probe display and extra audits/scopes - `threading.resolveAutoThreadId`: channel-owned same-conversation auto-threading - `threading.resolveReplyTransport`: channel-owned reply-vs-thread delivery mapping - `actions.requiresTrustedRequesterSender`: channel-owned privileged action trust gates diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 26a69cf79e0..1b0e003202c 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -38,8 +38,11 @@ import { import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js"; +import type { DiscordProbe } from "./probe.js"; import { getDiscordRuntime } from "./runtime.js"; +import { fetchChannelPermissionsDiscord } from "./send.js"; import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; +import { parseDiscordTarget } from "./targets.js"; import { DiscordUiContainer } from "./ui.js"; type DiscordSendFn = ReturnType< @@ -47,11 +50,27 @@ type DiscordSendFn = ReturnType< >["channel"]["discord"]["sendMessageDiscord"]; 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; + presence?: string; +}) { + if (!intents) { + return "unknown"; + } + return [ + `messageContent=${intents.messageContent ?? "unknown"}`, + `guildMembers=${intents.guildMembers ?? "unknown"}`, + `presence=${intents.presence ?? "unknown"}`, + ].join(" "); +} + const discordMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [], @@ -355,6 +374,91 @@ export const discordPlugin: ChannelPlugin = { getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, { includeApplication: true, }), + formatCapabilitiesProbe: ({ probe }) => { + const discordProbe = probe as DiscordProbe | undefined; + const lines = []; + if (discordProbe?.bot?.username) { + const botId = discordProbe.bot.id ? ` (${discordProbe.bot.id})` : ""; + lines.push({ text: `Bot: @${discordProbe.bot.username}${botId}` }); + } + if (discordProbe?.application?.intents) { + lines.push({ text: `Intents: ${formatDiscordIntents(discordProbe.application.intents)}` }); + } + return lines; + }, + buildCapabilitiesDiagnostics: async ({ account, timeoutMs, target }) => { + if (!target?.trim()) { + return undefined; + } + const parsedTarget = parseDiscordTarget(target.trim(), { defaultKind: "channel" }); + const details: Record = { + target: { + raw: target, + normalized: parsedTarget?.normalized, + kind: parsedTarget?.kind, + channelId: parsedTarget?.kind === "channel" ? parsedTarget.id : undefined, + }, + }; + if (!parsedTarget || parsedTarget.kind !== "channel") { + return { + details, + lines: [ + { + text: "Permissions: Target looks like a DM user; pass channel: to audit channel permissions.", + tone: "error", + }, + ], + }; + } + const token = account.token?.trim(); + if (!token) { + return { + details, + lines: [ + { + text: "Permissions: Discord bot token missing for permission audit.", + tone: "error", + }, + ], + }; + } + try { + const perms = await fetchChannelPermissionsDiscord(parsedTarget.id, { + token, + accountId: account.accountId ?? undefined, + }); + const missingRequired = REQUIRED_DISCORD_PERMISSIONS.filter( + (permission) => !perms.permissions.includes(permission), + ); + details.permissions = { + channelId: perms.channelId, + guildId: perms.guildId, + isDm: perms.isDm, + channelType: perms.channelType, + permissions: perms.permissions, + missingRequired, + raw: perms.raw, + }; + return { + details, + lines: [ + { + text: `Permissions (${perms.channelId}): ${perms.permissions.length ? perms.permissions.join(", ") : "none"}`, + }, + missingRequired.length > 0 + ? { text: `Missing required: ${missingRequired.join(", ")}`, tone: "warn" } + : { text: "Missing required: none", tone: "success" }, + ], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + details.permissions = { channelId: parsedTarget.id, error: message }; + return { + details, + lines: [{ text: `Permissions: ${message}`, tone: "error" }], + }; + } + }, auditAccount: async ({ account, timeoutMs, cfg }) => { const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({ cfg, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index a21aa451eb8..c4d3f41054c 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -17,6 +17,7 @@ import { PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/msteams"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; +import type { ProbeMSTeamsResult } from "./probe.js"; import { normalizeMSTeamsMessagingTarget, normalizeMSTeamsUserInput, @@ -47,6 +48,16 @@ const meta = { order: 60, } as const; +const TEAMS_GRAPH_PERMISSION_HINTS: Record = { + "ChannelMessage.Read.All": "channel history", + "Chat.Read.All": "chat history", + "Channel.ReadBasic.All": "channel list", + "Team.ReadBasic.All": "team list", + "TeamsActivity.Read.All": "teams activity", + "Sites.Read.All": "files (SharePoint)", + "Files.Read.All": "files (OneDrive)", +}; + async function loadMSTeamsChannelRuntime() { return await import("./channel.runtime.js"); } @@ -435,6 +446,40 @@ export const msteamsPlugin: ChannelPlugin = { }), probeAccount: async ({ cfg }) => await (await loadMSTeamsChannelRuntime()).probeMSTeams(cfg.channels?.msteams), + formatCapabilitiesProbe: ({ probe }) => { + const teamsProbe = probe as ProbeMSTeamsResult | undefined; + const lines: Array<{ text: string; tone?: "error" }> = []; + const appId = typeof teamsProbe?.appId === "string" ? teamsProbe.appId.trim() : ""; + if (appId) { + lines.push({ text: `App: ${appId}` }); + } + const graph = teamsProbe?.graph; + if (graph) { + const roles = Array.isArray(graph.roles) + ? graph.roles.map((role) => String(role).trim()).filter(Boolean) + : []; + const scopes = Array.isArray(graph.scopes) + ? graph.scopes.map((scope) => String(scope).trim()).filter(Boolean) + : []; + const formatPermission = (permission: string) => { + const hint = TEAMS_GRAPH_PERMISSION_HINTS[permission]; + return hint ? `${permission} (${hint})` : permission; + }; + if (graph.ok === false) { + lines.push({ text: `Graph: ${graph.error ?? "failed"}`, tone: "error" }); + } else if (roles.length > 0 || scopes.length > 0) { + if (roles.length > 0) { + lines.push({ text: `Graph roles: ${roles.map(formatPermission).join(", ")}` }); + } + if (scopes.length > 0) { + lines.push({ text: `Graph scopes: ${scopes.map(formatPermission).join(", ")}` }); + } + } else if (graph.ok === true) { + lines.push({ text: "Graph: ok" }); + } + } + return lines; + }, buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, enabled: account.enabled, diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 8b2f0998ff9..010df26d390 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -27,6 +27,7 @@ import { type ResolvedSignalAccount, } from "openclaw/plugin-sdk/signal"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; @@ -220,6 +221,10 @@ export const signalPlugin: ChannelPlugin = { const baseUrl = account.baseUrl; return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs); }, + formatCapabilitiesProbe: ({ probe }) => + (probe as SignalProbe | undefined)?.version + ? [{ text: `Signal daemon: ${(probe as SignalProbe).version}` }] + : [], buildAccountSnapshot: ({ account, runtime, probe }) => ({ ...buildBaseAccountStatusSnapshot({ account, runtime, probe }), baseUrl: account.baseUrl, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 04c9706bd95..f658b93d2c3 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -36,7 +36,10 @@ import { } from "openclaw/plugin-sdk/slack"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +import type { SlackProbe } from "./probe.js"; import { getSlackRuntime } from "./runtime.js"; +import { fetchSlackScopes } from "./scopes.js"; import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; import { parseSlackTarget } from "./targets.js"; @@ -126,6 +129,21 @@ function resolveSlackAutoThreadId(params: { return context.currentThreadTs; } +function formatSlackScopeDiagnostic(params: { + tokenType: "bot" | "user"; + result: Awaited>; +}) { + const source = params.result.source ? ` (${params.result.source})` : ""; + const label = params.tokenType === "user" ? "User scopes" : "Bot scopes"; + if (params.result.ok && params.result.scopes?.length) { + return { text: `${label}${source}: ${params.result.scopes.join(", ")}` } as const; + } + return { + text: `${label}: ${params.result.error ?? "scope lookup failed"}`, + tone: "error", + } as const; +} + const slackConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, @@ -285,6 +303,17 @@ export const slackPlugin: ChannelPlugin = { normalizeTarget: normalizeSlackMessagingTarget, enableInteractiveReplies: ({ cfg, accountId }) => isSlackInteractiveRepliesEnabled({ cfg, accountId }), + hasStructuredReplyPayload: ({ payload }) => { + const slackData = payload.channelData?.slack; + if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) { + return false; + } + try { + return Boolean(parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks)?.length); + } catch { + return false; + } + }, targetResolver: { looksLikeId: looksLikeSlackTargetId, hint: "", @@ -429,6 +458,35 @@ export const slackPlugin: ChannelPlugin = { } return await getSlackRuntime().channel.slack.probeSlack(token, timeoutMs); }, + formatCapabilitiesProbe: ({ probe }) => { + const slackProbe = probe as SlackProbe | undefined; + const lines = []; + if (slackProbe?.bot?.name) { + lines.push({ text: `Bot: @${slackProbe.bot.name}` }); + } + if (slackProbe?.team?.name || slackProbe?.team?.id) { + const id = slackProbe.team?.id ? ` (${slackProbe.team.id})` : ""; + lines.push({ text: `Team: ${slackProbe.team?.name ?? "unknown"}${id}` }); + } + return lines; + }, + buildCapabilitiesDiagnostics: async ({ account, timeoutMs }) => { + const lines = []; + const details: Record = {}; + const botToken = account.botToken?.trim(); + const userToken = account.config.userToken?.trim(); + const botScopes = botToken + ? await fetchSlackScopes(botToken, timeoutMs) + : { ok: false, error: "Slack bot token missing." }; + lines.push(formatSlackScopeDiagnostic({ tokenType: "bot", result: botScopes })); + details.botScopes = botScopes; + if (userToken) { + const userScopes = await fetchSlackScopes(userToken, timeoutMs); + lines.push(formatSlackScopeDiagnostic({ tokenType: "user", result: userScopes })); + details.userScopes = userScopes; + } + return { lines, details }; + }, buildAccountSnapshot: ({ account, runtime, probe }) => { const mode = account.config.mode ?? "socket"; const configured = diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 1f0d94057a2..2aebfe5652c 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -54,7 +54,6 @@ import { sendTypingTelegram } from "./send.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; import { parseTelegramTarget } from "./targets.js"; -import { deleteTelegramUpdateOffset } from "./update-offset-store.js"; type TelegramSendFn = ReturnType< typeof getTelegramRuntime @@ -334,10 +333,12 @@ export const telegramPlugin: ChannelPlugin { + const { deleteTelegramUpdateOffset } = await import("./update-offset-store.js"); await deleteTelegramUpdateOffset({ accountId }); }, }, @@ -515,6 +516,30 @@ export const telegramPlugin: ChannelPlugin { + const lines = []; + if (probe?.bot?.username) { + const botId = probe.bot.id ? ` (${probe.bot.id})` : ""; + lines.push({ text: `Bot: @${probe.bot.username}${botId}` }); + } + const flags: string[] = []; + if (typeof probe?.bot?.canJoinGroups === "boolean") { + flags.push(`joinGroups=${probe.bot.canJoinGroups}`); + } + if (typeof probe?.bot?.canReadAllGroupMessages === "boolean") { + flags.push(`readAllGroupMessages=${probe.bot.canReadAllGroupMessages}`); + } + if (typeof probe?.bot?.supportsInlineQueries === "boolean") { + flags.push(`inlineQueries=${probe.bot.supportsInlineQueries}`); + } + if (flags.length > 0) { + lines.push({ text: `Flags: ${flags.join(" ")}` }); + } + if (probe?.webhook?.url !== undefined) { + lines.push({ text: `Webhook: ${probe.webhook.url || "none"}` }); + } + return lines; + }, auditAccount: async ({ account, timeoutMs, probe, cfg }) => { const groups = cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 0a717f9bfc7..c0023ae1c37 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js"; +import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { discordOutbound } from "../../channels/plugins/outbound/discord.js"; import { imessageOutbound } from "../../channels/plugins/outbound/imessage.js"; import { signalOutbound } from "../../channels/plugins/outbound/signal.js"; @@ -540,7 +541,11 @@ const defaultRegistry = createTestRegistry([ }, { pluginId: "slack", - plugin: createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }), + plugin: { + ...createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }), + messaging: slackPlugin.messaging, + threading: slackPlugin.threading, + }, source: "test", }, { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 15036d0878f..8dc7499526a 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -7,8 +7,6 @@ * across multiple providers. */ -import { parseSlackBlocksInput } from "../../../extensions/slack/src/blocks-input.js"; -import { isSlackInteractiveRepliesEnabled } from "../../../extensions/slack/src/interactive-replies.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; @@ -101,9 +99,10 @@ export async function routeReply(params: RouteReplyParams): Promise 0; - let hasChannelData = - externalPayload.channelData != null && Object.keys(externalPayload.channelData).length > 0; - if ( - channel === "slack" && - externalPayload.channelData?.slack && - typeof externalPayload.channelData.slack === "object" && - !Array.isArray(externalPayload.channelData.slack) - ) { - try { - hasChannelData = Boolean( - parseSlackBlocksInput((externalPayload.channelData.slack as { blocks?: unknown }).blocks) - ?.length, - ); - } catch { - hasChannelData = false; - } - } + const hasChannelData = plugin?.messaging?.hasStructuredReplyPayload?.({ + payload: externalPayload, + }); // Skip empty replies. if (!text.trim() && mediaUrls.length === 0 && !hasInteractive && !hasChannelData) { diff --git a/src/channels/plugins/message-actions.security.test.ts b/src/channels/plugins/message-actions.security.test.ts index 1dbd19de3e0..b8b62afdecd 100644 --- a/src/channels/plugins/message-actions.security.test.ts +++ b/src/channels/plugins/message-actions.security.test.ts @@ -25,6 +25,8 @@ const discordPlugin: ChannelPlugin = { actions: { listActions: () => ["kick"], supportsAction: ({ action }) => action === "kick", + requiresTrustedRequesterSender: ({ action, toolContext }) => + Boolean(action === "kick" && toolContext), handleAction, }, }; diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 53bc14cfc10..506f2204493 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -6,23 +6,13 @@ import type { ChannelMessageActionContext, ChannelMessageActionName } from "./ty type ChannelActions = NonNullable>["actions"]>; -const trustedRequesterRequiredByChannel: Readonly< - Partial>> -> = { - discord: new Set(["timeout", "kick", "ban"]), -}; - function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean { const plugin = getChannelPlugin(ctx.channel); - const fromPlugin = plugin?.actions?.requiresTrustedRequesterSender?.({ - action: ctx.action, - toolContext: ctx.toolContext, - }); - if (fromPlugin != null) { - return fromPlugin; - } return Boolean( - trustedRequesterRequiredByChannel[ctx.channel]?.has(ctx.action) && ctx.toolContext, + plugin?.actions?.requiresTrustedRequesterSender?.({ + action: ctx.action, + toolContext: ctx.toolContext, + }), ); } diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index c8255f07542..084fa653bb8 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -35,6 +35,22 @@ export type ChannelExecApprovalForwardTarget = { source?: "session" | "target"; }; +export type ChannelCapabilitiesDisplayTone = "default" | "muted" | "success" | "warn" | "error"; + +export type ChannelCapabilitiesDisplayLine = { + text: string; + tone?: ChannelCapabilitiesDisplayTone; +}; + +export type ChannelCapabilitiesDiagnostics = { + lines?: ChannelCapabilitiesDisplayLine[]; + details?: Record; +}; + +type BivariantCallback unknown> = { + bivarianceHack: T; +}["bivarianceHack"]; + export type ChannelSetupAdapter = { resolveAccountId?: (params: { cfg: OpenClawConfig; @@ -153,12 +169,25 @@ export type ChannelStatusAdapter Promise; + formatCapabilitiesProbe?: BivariantCallback< + (params: { probe: Probe }) => ChannelCapabilitiesDisplayLine[] + >; auditAccount?: (params: { account: ResolvedAccount; timeoutMs: number; cfg: OpenClawConfig; probe?: Probe; }) => Promise; + buildCapabilitiesDiagnostics?: BivariantCallback< + (params: { + account: ResolvedAccount; + timeoutMs: number; + cfg: OpenClawConfig; + probe?: Probe; + audit?: Audit; + target?: string; + }) => Promise + >; buildAccountSnapshot?: (params: { account: ResolvedAccount; cfg: OpenClawConfig; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 22b2c9387e7..4d94afe49fd 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -2,6 +2,7 @@ import type { TopLevelComponents } from "@buape/carbon"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { TSchema } from "@sinclair/typebox"; import type { MsgContext } from "../../auto-reply/templating.js"; +import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { PollInput } from "../../polls.js"; import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js"; @@ -349,6 +350,7 @@ export type ChannelMessagingAdapter = { cfg: OpenClawConfig; accountId?: string | null; }) => boolean; + hasStructuredReplyPayload?: (params: { payload: ReplyPayload }) => boolean; targetResolver?: { looksLikeId?: (raw: string, normalized?: string) => boolean; hint?: string; diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index a2abcc12dea..ffa098f0673 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -9,6 +9,9 @@ export type { ChannelMessageCapability } from "./message-capabilities.js"; export type { ChannelAuthAdapter, ChannelCommandAdapter, + ChannelCapabilitiesDiagnostics, + ChannelCapabilitiesDisplayLine, + ChannelCapabilitiesDisplayTone, ChannelConfigAdapter, ChannelDirectoryAdapter, ChannelExecApprovalAdapter, diff --git a/src/channels/reply-prefix.ts b/src/channels/reply-prefix.ts index c76b6175157..cfda423eeb9 100644 --- a/src/channels/reply-prefix.ts +++ b/src/channels/reply-prefix.ts @@ -1,4 +1,3 @@ -import { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/src/interactive-replies.js"; import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js"; import { extractShortModelName, @@ -55,11 +54,7 @@ export function createReplyPrefixContext(params: { ? (getChannelPlugin(params.channel)?.messaging?.enableInteractiveReplies?.({ cfg, accountId: params.accountId, - }) ?? - (params.channel === "slack" - ? isSlackInteractiveRepliesEnabled({ cfg, accountId: params.accountId }) - : undefined) ?? - undefined) + }) ?? undefined) : undefined, responsePrefixContextProvider: () => prefixContext, onModelSelected, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 0079e7ea881..4f8b3e8133c 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -321,12 +321,6 @@ export async function channelsAddCommand( }); } - let previousTelegramToken = ""; - if (channel === "telegram") { - const { resolveTelegramAccount } = await import("../../../extensions/telegram/src/accounts.js"); - previousTelegramToken = resolveTelegramAccount({ cfg: prevConfig, accountId }).token.trim(); - } - nextConfig = applyChannelAccountConfig({ cfg: nextConfig, channel, @@ -340,16 +334,6 @@ export async function channelsAddCommand( accountId, runtime, }); - if (channel === "telegram") { - const [{ resolveTelegramAccount }, { deleteTelegramUpdateOffset }] = await Promise.all([ - import("../../../extensions/telegram/src/accounts.js"), - import("../../../extensions/telegram/src/update-offset-store.js"), - ]); - const nextTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim(); - if (previousTelegramToken !== nextTelegramToken) { - await deleteTelegramUpdateOffset({ accountId }); - } - } await writeConfigFile(nextConfig); runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`); diff --git a/src/commands/channels/capabilities.test.ts b/src/commands/channels/capabilities.test.ts index 5e838cc4ec8..3a70bdb85f9 100644 --- a/src/commands/channels/capabilities.test.ts +++ b/src/commands/channels/capabilities.test.ts @@ -1,7 +1,6 @@ process.env.NO_COLOR = "1"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fetchSlackScopes } from "../../../extensions/slack/src/scopes.js"; import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import { channelsCapabilitiesCommand } from "./capabilities.js"; @@ -21,10 +20,6 @@ vi.mock("../../channels/plugins/index.js", () => ({ getChannelPlugin: vi.fn(), })); -vi.mock("../../../extensions/slack/src/scopes.js", () => ({ - fetchSlackScopes: vi.fn(), -})); - const runtime = { log: (...args: unknown[]) => { logs.push(args.map(String).join(" ")); @@ -95,14 +90,22 @@ describe("channelsCapabilitiesCommand", () => { }, probe: { ok: true, bot: { name: "openclaw" }, team: { name: "team" } }, }); + plugin.status = { + ...plugin.status, + formatCapabilitiesProbe: () => [{ text: "Bot: @openclaw" }, { text: "Team: team" }], + buildCapabilitiesDiagnostics: async () => ({ + lines: [ + { text: "Bot scopes (auth.scopes): chat:write" }, + { text: "User scopes (auth.scopes): users:read" }, + ], + details: { + botScopes: { ok: true, scopes: ["chat:write"], source: "auth.scopes" }, + userScopes: { ok: true, scopes: ["users:read"], source: "auth.scopes" }, + }, + }), + }; vi.mocked(listChannelPlugins).mockReturnValue([plugin]); vi.mocked(getChannelPlugin).mockReturnValue(plugin); - vi.mocked(fetchSlackScopes).mockImplementation(async (token: string) => { - if (token === "xoxp-user") { - return { ok: true, scopes: ["users:read"], source: "auth.scopes" }; - } - return { ok: true, scopes: ["chat:write"], source: "auth.scopes" }; - }); await channelsCapabilitiesCommand({ channel: "slack" }, runtime); @@ -111,8 +114,6 @@ describe("channelsCapabilitiesCommand", () => { expect(output).toContain("User scopes"); expect(output).toContain("chat:write"); expect(output).toContain("users:read"); - expect(fetchSlackScopes).toHaveBeenCalledWith("xoxb-bot", expect.any(Number)); - expect(fetchSlackScopes).toHaveBeenCalledWith("xoxp-user", expect.any(Number)); }); it("prints Teams Graph permission hints when present", async () => { @@ -127,6 +128,15 @@ describe("channelsCapabilitiesCommand", () => { }, }, }); + plugin.status = { + ...plugin.status, + formatCapabilitiesProbe: () => [ + { text: "App: app-id" }, + { + text: "Graph roles: ChannelMessage.Read.All (channel history), Files.Read.All (files (OneDrive))", + }, + ], + }; vi.mocked(listChannelPlugins).mockReturnValue([plugin]); vi.mocked(getChannelPlugin).mockReturnValue(plugin); diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index 30f64da43d9..acd28137b30 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -1,9 +1,11 @@ -import { fetchChannelPermissionsDiscord } from "../../../extensions/discord/src/send.js"; -import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js"; -import { fetchSlackScopes, type SlackScopesResult } from "../../../extensions/slack/src/scopes.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; -import type { ChannelCapabilities, ChannelPlugin } from "../../channels/plugins/types.js"; +import type { + ChannelCapabilities, + ChannelCapabilitiesDiagnostics, + ChannelCapabilitiesDisplayLine, + ChannelPlugin, +} from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { danger } from "../../globals.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; @@ -18,24 +20,6 @@ export type ChannelsCapabilitiesOptions = { json?: boolean; }; -type DiscordTargetSummary = { - raw?: string; - normalized?: string; - kind?: "channel" | "user"; - channelId?: string; -}; - -type DiscordPermissionsReport = { - channelId?: string; - guildId?: string; - isDm?: boolean; - channelType?: number; - permissions?: string[]; - missingRequired?: string[]; - raw?: string; - error?: string; -}; - type ChannelCapabilitiesReport = { channel: string; accountId: string; @@ -45,24 +29,7 @@ type ChannelCapabilitiesReport = { support?: ChannelCapabilities; actions?: string[]; probe?: unknown; - slackScopes?: Array<{ - tokenType: "bot" | "user"; - result: SlackScopesResult; - }>; - target?: DiscordTargetSummary; - channelPermissions?: DiscordPermissionsReport; -}; - -const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; - -const TEAMS_GRAPH_PERMISSION_HINTS: Record = { - "ChannelMessage.Read.All": "channel history", - "Chat.Read.All": "chat history", - "Channel.ReadBasic.All": "channel list", - "Team.ReadBasic.All": "team list", - "TeamsActivity.Read.All": "teams activity", - "Sites.Read.All": "files (SharePoint)", - "Files.Read.All": "files (OneDrive)", + diagnostics?: ChannelCapabilitiesDiagnostics; }; function normalizeTimeout(raw: unknown, fallback = 10_000) { @@ -117,221 +84,35 @@ function formatSupport(capabilities?: ChannelCapabilities) { return bits.length ? bits.join(" ") : "none"; } -function summarizeDiscordTarget(raw?: string): DiscordTargetSummary | undefined { - if (!raw) { - return undefined; - } - const target = parseDiscordTarget(raw, { defaultKind: "channel" }); - if (!target) { - return { raw }; - } - if (target.kind === "channel") { - return { - raw, - normalized: target.normalized, - kind: "channel", - channelId: target.id, - }; - } - if (target.kind === "user") { - return { - raw, - normalized: target.normalized, - kind: "user", - }; - } - return { raw, normalized: target.normalized }; -} - -function formatDiscordIntents(intents?: { - messageContent?: string; - guildMembers?: string; - presence?: string; -}) { - if (!intents) { - return "unknown"; - } - return [ - `messageContent=${intents.messageContent ?? "unknown"}`, - `guildMembers=${intents.guildMembers ?? "unknown"}`, - `presence=${intents.presence ?? "unknown"}`, - ].join(" "); -} - -function formatProbeLines(channelId: string, probe: unknown): string[] { - const lines: string[] = []; +function formatGenericProbeLines(probe: unknown): ChannelCapabilitiesDisplayLine[] { if (!probe || typeof probe !== "object") { - return lines; + return []; } const probeObj = probe as Record; - - if (channelId === "discord") { - const bot = probeObj.bot as { id?: string | null; username?: string | null } | undefined; - if (bot?.username) { - const botId = bot.id ? ` (${bot.id})` : ""; - lines.push(`Bot: ${theme.accent(`@${bot.username}`)}${botId}`); - } - const app = probeObj.application as { intents?: Record } | undefined; - if (app?.intents) { - lines.push(`Intents: ${formatDiscordIntents(app.intents)}`); - } - } - - if (channelId === "telegram") { - const bot = probeObj.bot as { username?: string | null; id?: number | null } | undefined; - if (bot?.username) { - const botId = bot.id ? ` (${bot.id})` : ""; - lines.push(`Bot: ${theme.accent(`@${bot.username}`)}${botId}`); - } - const flags: string[] = []; - const canJoinGroups = (bot as { canJoinGroups?: boolean | null })?.canJoinGroups; - const canReadAll = (bot as { canReadAllGroupMessages?: boolean | null }) - ?.canReadAllGroupMessages; - const inlineQueries = (bot as { supportsInlineQueries?: boolean | null }) - ?.supportsInlineQueries; - if (typeof canJoinGroups === "boolean") { - flags.push(`joinGroups=${canJoinGroups}`); - } - if (typeof canReadAll === "boolean") { - flags.push(`readAllGroupMessages=${canReadAll}`); - } - if (typeof inlineQueries === "boolean") { - flags.push(`inlineQueries=${inlineQueries}`); - } - if (flags.length > 0) { - lines.push(`Flags: ${flags.join(" ")}`); - } - const webhook = probeObj.webhook as { url?: string | null } | undefined; - if (webhook?.url !== undefined) { - lines.push(`Webhook: ${webhook.url || "none"}`); - } - } - - if (channelId === "slack") { - const bot = probeObj.bot as { name?: string } | undefined; - const team = probeObj.team as { name?: string; id?: string } | undefined; - if (bot?.name) { - lines.push(`Bot: ${theme.accent(`@${bot.name}`)}`); - } - if (team?.name || team?.id) { - const id = team?.id ? ` (${team.id})` : ""; - lines.push(`Team: ${team?.name ?? "unknown"}${id}`); - } - } - - if (channelId === "signal") { - const version = probeObj.version as string | null | undefined; - if (version) { - lines.push(`Signal daemon: ${version}`); - } - } - - if (channelId === "msteams") { - const appId = typeof probeObj.appId === "string" ? probeObj.appId.trim() : ""; - if (appId) { - lines.push(`App: ${theme.accent(appId)}`); - } - const graph = probeObj.graph as - | { ok?: boolean; roles?: unknown; scopes?: unknown; error?: string } - | undefined; - if (graph) { - const roles = Array.isArray(graph.roles) - ? graph.roles.map((role) => String(role).trim()).filter(Boolean) - : []; - const scopes = - typeof graph.scopes === "string" - ? graph.scopes - .split(/\s+/) - .map((scope) => scope.trim()) - .filter(Boolean) - : Array.isArray(graph.scopes) - ? graph.scopes.map((scope) => String(scope).trim()).filter(Boolean) - : []; - if (graph.ok === false) { - lines.push(`Graph: ${theme.error(graph.error ?? "failed")}`); - } else if (roles.length > 0 || scopes.length > 0) { - const formatPermission = (permission: string) => { - const hint = TEAMS_GRAPH_PERMISSION_HINTS[permission]; - return hint ? `${permission} (${hint})` : permission; - }; - if (roles.length > 0) { - lines.push(`Graph roles: ${roles.map(formatPermission).join(", ")}`); - } - if (scopes.length > 0) { - lines.push(`Graph scopes: ${scopes.map(formatPermission).join(", ")}`); - } - } else if (graph.ok === true) { - lines.push("Graph: ok"); - } - } - } - const ok = typeof probeObj.ok === "boolean" ? probeObj.ok : undefined; - if (ok === true && lines.length === 0) { - lines.push("Probe: ok"); + if (ok === true) { + return [{ text: "Probe: ok" }]; } if (ok === false) { const error = typeof probeObj.error === "string" && probeObj.error ? ` (${probeObj.error})` : ""; - lines.push(`Probe: ${theme.error(`failed${error}`)}`); + return [{ text: `Probe: failed${error}`, tone: "error" }]; } - return lines; + return []; } -async function buildDiscordPermissions(params: { - account: { token?: string; accountId?: string }; - target?: string; -}): Promise<{ target?: DiscordTargetSummary; report?: DiscordPermissionsReport }> { - const target = summarizeDiscordTarget(params.target?.trim()); - if (!target) { - return {}; - } - if (target.kind !== "channel" || !target.channelId) { - return { - target, - report: { - error: "Target looks like a DM user; pass channel: to audit channel permissions.", - }, - }; - } - const token = params.account.token?.trim(); - if (!token) { - return { - target, - report: { - channelId: target.channelId, - error: "Discord bot token missing for permission audit.", - }, - }; - } - try { - const perms = await fetchChannelPermissionsDiscord(target.channelId, { - token, - accountId: params.account.accountId ?? undefined, - }); - const missing = REQUIRED_DISCORD_PERMISSIONS.filter( - (permission) => !perms.permissions.includes(permission), - ); - return { - target, - report: { - channelId: perms.channelId, - guildId: perms.guildId, - isDm: perms.isDm, - channelType: perms.channelType, - permissions: perms.permissions, - missingRequired: missing.length ? missing : [], - raw: perms.raw, - }, - }; - } catch (err) { - return { - target, - report: { - channelId: target.channelId, - error: err instanceof Error ? err.message : String(err), - }, - }; +function renderDisplayLine(line: ChannelCapabilitiesDisplayLine) { + switch (line.tone) { + case "muted": + return theme.muted(line.text); + case "success": + return theme.success(line.text); + case "warn": + return theme.warn(line.text); + case "error": + return theme.error(line.text); + default: + return line.text; } } @@ -378,41 +159,16 @@ async function resolveChannelReports(params: { } } - let slackScopes: ChannelCapabilitiesReport["slackScopes"]; - if (plugin.id === "slack" && configured && enabled) { - const botToken = (resolvedAccount as { botToken?: string }).botToken?.trim(); - const userToken = (resolvedAccount as { userToken?: string }).userToken?.trim(); - const scopeReports: NonNullable = []; - if (botToken) { - scopeReports.push({ - tokenType: "bot", - result: await fetchSlackScopes(botToken, timeoutMs), - }); - } else { - scopeReports.push({ - tokenType: "bot", - result: { ok: false, error: "Slack bot token missing." }, - }); - } - if (userToken) { - scopeReports.push({ - tokenType: "user", - result: await fetchSlackScopes(userToken, timeoutMs), - }); - } - slackScopes = scopeReports; - } - - let discordTarget: DiscordTargetSummary | undefined; - let discordPermissions: DiscordPermissionsReport | undefined; - if (plugin.id === "discord" && params.target) { - const perms = await buildDiscordPermissions({ - account: resolvedAccount as { token?: string; accountId?: string }, - target: params.target, - }); - discordTarget = perms.target; - discordPermissions = perms.report; - } + const diagnostics = + configured && enabled + ? await plugin.status?.buildCapabilitiesDiagnostics?.({ + account: resolvedAccount, + timeoutMs, + cfg, + probe, + target: params.target, + }) + : undefined; reports.push({ channel: plugin.id, @@ -425,10 +181,8 @@ async function resolveChannelReports(params: { enabled, support: plugin.capabilities, probe, - target: discordTarget, - channelPermissions: discordPermissions, actions, - slackScopes, + diagnostics, }); } return reports; @@ -451,8 +205,8 @@ export async function channelsCapabilitiesCommand( runtime.exit(1); return; } - if (rawTarget && rawChannel !== "discord") { - runtime.error(danger("--target requires --channel discord.")); + if (rawTarget && (!rawChannel || rawChannel === "all")) { + runtime.error(danger("--target requires a specific --channel.")); runtime.exit(1); return; } @@ -484,7 +238,7 @@ export async function channelsCapabilitiesCommand( cfg, timeoutMs, accountOverride, - target: rawTarget && plugin.id === "discord" ? rawTarget : undefined, + target: rawTarget || undefined, })), ); } @@ -513,39 +267,17 @@ export async function channelsCapabilitiesCommand( const enabledLabel = report.enabled === false ? "disabled" : "enabled"; lines.push(`Status: ${configuredLabel}, ${enabledLabel}`); } - const probeLines = formatProbeLines(report.channel, report.probe); + const probeLines = + getChannelPlugin(report.channel)?.status?.formatCapabilitiesProbe?.({ + probe: report.probe, + }) ?? formatGenericProbeLines(report.probe); if (probeLines.length > 0) { - lines.push(...probeLines); + lines.push(...probeLines.map(renderDisplayLine)); } else if (report.configured && report.enabled) { lines.push(theme.muted("Probe: unavailable")); } - if (report.channel === "slack" && report.slackScopes) { - for (const entry of report.slackScopes) { - const source = entry.result.source ? ` (${entry.result.source})` : ""; - const label = entry.tokenType === "user" ? "User scopes" : "Bot scopes"; - if (entry.result.ok && entry.result.scopes?.length) { - lines.push(`${label}${source}: ${entry.result.scopes.join(", ")}`); - } else if (entry.result.error) { - lines.push(`${label}: ${theme.error(entry.result.error)}`); - } - } - } - if (report.channel === "discord" && report.channelPermissions) { - const perms = report.channelPermissions; - if (perms.error) { - lines.push(`Permissions: ${theme.error(perms.error)}`); - } else { - const list = perms.permissions?.length ? perms.permissions.join(", ") : "none"; - const label = perms.channelId ? ` (${perms.channelId})` : ""; - lines.push(`Permissions${label}: ${list}`); - if (perms.missingRequired && perms.missingRequired.length > 0) { - lines.push(`${theme.warn("Missing required:")} ${perms.missingRequired.join(", ")}`); - } else { - lines.push(theme.success("Missing required: none")); - } - } - } else if (report.channel === "discord" && rawTarget && !report.channelPermissions) { - lines.push(theme.muted("Permissions: skipped (no target).")); + if (report.diagnostics?.lines?.length) { + lines.push(...report.diagnostics.lines.map(renderDisplayLine)); } lines.push(""); } diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index b7d012d0fac..1cd5fded7d3 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -118,11 +118,6 @@ export async function channelsRemoveCommand( accountId: resolvedAccountId, runtime, }); - if (channel === "telegram") { - const { deleteTelegramUpdateOffset } = - await import("../../../extensions/telegram/src/update-offset-store.js"); - await deleteTelegramUpdateOffset({ accountId: resolvedAccountId }); - } } else { if (!plugin.config.setAccountEnabled) { runtime.error(`Channel ${channel} does not support disable.`); From 9cd9c7a4884177ecfbd4e040e2487701a2d1bb66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:21:52 +0000 Subject: [PATCH 016/133] refactor: split slack block action handling --- .../events/interactions.block-actions.ts | 773 ++++++++++++++++++ .../slack/src/monitor/events/interactions.ts | 607 +------------- 2 files changed, 781 insertions(+), 599 deletions(-) create mode 100644 extensions/slack/src/monitor/events/interactions.block-actions.ts diff --git a/extensions/slack/src/monitor/events/interactions.block-actions.ts b/extensions/slack/src/monitor/events/interactions.block-actions.ts new file mode 100644 index 00000000000..1f54df45a5d --- /dev/null +++ b/extensions/slack/src/monitor/events/interactions.block-actions.ts @@ -0,0 +1,773 @@ +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"; +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"; +import { escapeSlackMrkdwn } from "../mrkdwn.js"; + +type InteractionMessageBlock = { + type?: string; + block_id?: string; + elements?: Array<{ action_id?: string }>; +}; + +type SelectOption = { + value?: string; + text?: { text?: string }; +}; + +type InteractionSelectionFields = { + blockId?: string; + callbackId?: string; + value?: string; + inputKind?: "number" | "text" | "url" | "email" | "rich_text"; + inputValue?: string; + inputNumber?: number; + inputEmail?: string; + inputUrl?: string; + richTextValue?: unknown; + richTextPreview?: string; + selectedValues?: string[]; + selectedUsers?: string[]; + selectedChannels?: string[]; + selectedConversations?: string[]; + selectedLabels?: string[]; + selectedDate?: string; + selectedTime?: string; + selectedDateTime?: number; + actionType?: string; + viewId?: string; + privateMetadata?: string; + viewHash?: string; + inputs?: unknown[]; + isCleared?: boolean; + routedChannelType?: string; + routedChannelId?: string; +}; + +export type InteractionSummary = InteractionSelectionFields & { + interactionType?: "block_action" | "view_submission" | "view_closed"; + actionId: string; + userId?: string; + teamId?: string; + triggerId?: string; + responseUrl?: string; + workflowTriggerUrl?: string; + workflowId?: string; + channelId?: string; + messageTs?: string; + threadTs?: string; +}; + +type SlackActionSummary = Omit; + +type SlackBlockActionBody = { + user?: { id?: string }; + team?: { id?: string }; + trigger_id?: string; + response_url?: string; + channel?: { id?: string }; + container?: { channel_id?: string; message_ts?: string; thread_ts?: string }; + message?: { ts?: string; text?: string; blocks?: unknown[] }; +}; + +type SlackBlockActionRespond = NonNullable; + +type ParsedSlackBlockAction = { + typedBody: SlackBlockActionBody; + typedAction: Record; + typedActionWithText: { + action_id?: string; + block_id?: string; + type?: string; + text?: { text?: string }; + }; + actionId: string; + blockId?: string; + userId: string; + channelId?: string; + messageTs?: string; + threadTs?: string; + actionSummary: SlackActionSummary; +}; + +function readOptionValues(options: unknown): string[] | undefined { + if (!Array.isArray(options)) { + return undefined; + } + const values = options + .map((option) => (option && typeof option === "object" ? (option as SelectOption).value : null)) + .filter((value): value is string => typeof value === "string" && value.trim().length > 0); + return values.length > 0 ? values : undefined; +} + +function readOptionLabels(options: unknown): string[] | undefined { + if (!Array.isArray(options)) { + return undefined; + } + const labels = options + .map((option) => + option && typeof option === "object" ? ((option as SelectOption).text?.text ?? null) : null, + ) + .filter((label): label is string => typeof label === "string" && label.trim().length > 0); + return labels.length > 0 ? labels : undefined; +} + +function uniqueNonEmptyStrings(values: string[]): string[] { + const unique: string[] = []; + const seen = new Set(); + for (const entry of values) { + if (typeof entry !== "string") { + continue; + } + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + unique.push(trimmed); + } + return unique; +} + +function collectRichTextFragments(value: unknown, out: string[]): void { + if (!value || typeof value !== "object") { + return; + } + const typed = value as { text?: unknown; elements?: unknown }; + if (typeof typed.text === "string" && typed.text.trim().length > 0) { + out.push(typed.text.trim()); + } + if (Array.isArray(typed.elements)) { + for (const child of typed.elements) { + collectRichTextFragments(child, out); + } + } +} + +function summarizeRichTextPreview(value: unknown): string | undefined { + const fragments: string[] = []; + collectRichTextFragments(value, fragments); + if (fragments.length === 0) { + return undefined; + } + const joined = fragments.join(" ").replace(/\s+/g, " ").trim(); + if (!joined) { + return undefined; + } + const max = 120; + return joined.length <= max ? joined : `${joined.slice(0, max - 1)}…`; +} + +function readInteractionAction(raw: unknown) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + return raw as Record; +} + +export function summarizeAction(action: Record): SlackActionSummary { + const typed = action as { + type?: string; + selected_option?: SelectOption; + selected_options?: SelectOption[]; + selected_user?: string; + selected_users?: string[]; + selected_channel?: string; + selected_channels?: string[]; + selected_conversation?: string; + selected_conversations?: string[]; + selected_date?: string; + selected_time?: string; + selected_date_time?: number; + value?: string; + rich_text_value?: unknown; + workflow?: { + trigger_url?: string; + workflow_id?: string; + }; + }; + const actionType = typed.type; + const selectedUsers = uniqueNonEmptyStrings([ + ...(typed.selected_user ? [typed.selected_user] : []), + ...(Array.isArray(typed.selected_users) ? typed.selected_users : []), + ]); + const selectedChannels = uniqueNonEmptyStrings([ + ...(typed.selected_channel ? [typed.selected_channel] : []), + ...(Array.isArray(typed.selected_channels) ? typed.selected_channels : []), + ]); + const selectedConversations = uniqueNonEmptyStrings([ + ...(typed.selected_conversation ? [typed.selected_conversation] : []), + ...(Array.isArray(typed.selected_conversations) ? typed.selected_conversations : []), + ]); + const selectedValues = uniqueNonEmptyStrings([ + ...(typed.selected_option?.value ? [typed.selected_option.value] : []), + ...(readOptionValues(typed.selected_options) ?? []), + ...selectedUsers, + ...selectedChannels, + ...selectedConversations, + ]); + const selectedLabels = uniqueNonEmptyStrings([ + ...(typed.selected_option?.text?.text ? [typed.selected_option.text.text] : []), + ...(readOptionLabels(typed.selected_options) ?? []), + ]); + const inputValue = typeof typed.value === "string" ? typed.value : undefined; + const inputNumber = + actionType === "number_input" && inputValue != null ? Number.parseFloat(inputValue) : undefined; + const parsedNumber = Number.isFinite(inputNumber) ? inputNumber : undefined; + const inputEmail = + actionType === "email_text_input" && inputValue?.includes("@") ? inputValue : undefined; + let inputUrl: string | undefined; + if (actionType === "url_text_input" && inputValue) { + try { + inputUrl = new URL(inputValue).toString(); + } catch { + inputUrl = undefined; + } + } + const richTextValue = actionType === "rich_text_input" ? typed.rich_text_value : undefined; + const richTextPreview = summarizeRichTextPreview(richTextValue); + const inputKind = + actionType === "number_input" + ? "number" + : actionType === "email_text_input" + ? "email" + : actionType === "url_text_input" + ? "url" + : actionType === "rich_text_input" + ? "rich_text" + : inputValue != null + ? "text" + : undefined; + + return { + actionType, + inputKind, + value: typed.value, + selectedValues: selectedValues.length > 0 ? selectedValues : undefined, + selectedUsers: selectedUsers.length > 0 ? selectedUsers : undefined, + selectedChannels: selectedChannels.length > 0 ? selectedChannels : undefined, + selectedConversations: selectedConversations.length > 0 ? selectedConversations : undefined, + selectedLabels: selectedLabels.length > 0 ? selectedLabels : undefined, + selectedDate: typed.selected_date, + selectedTime: typed.selected_time, + selectedDateTime: + typeof typed.selected_date_time === "number" ? typed.selected_date_time : undefined, + inputValue, + inputNumber: parsedNumber, + inputEmail, + inputUrl, + richTextValue, + richTextPreview, + workflowTriggerUrl: typed.workflow?.trigger_url, + workflowId: typed.workflow?.workflow_id, + }; +} + +function isBulkActionsBlock(block: InteractionMessageBlock): boolean { + return ( + block.type === "actions" && + Array.isArray(block.elements) && + block.elements.length > 0 && + block.elements.every((el) => typeof el.action_id === "string" && el.action_id.includes("_all_")) + ); +} + +function formatInteractionSelectionLabel(params: { + actionId: string; + summary: SlackActionSummary; + buttonText?: string; +}): string { + if (params.summary.actionType === "button" && params.buttonText?.trim()) { + return params.buttonText.trim(); + } + if (params.summary.selectedLabels?.length) { + if (params.summary.selectedLabels.length <= 3) { + return params.summary.selectedLabels.join(", "); + } + return `${params.summary.selectedLabels.slice(0, 3).join(", ")} +${ + params.summary.selectedLabels.length - 3 + }`; + } + if (params.summary.selectedValues?.length) { + if (params.summary.selectedValues.length <= 3) { + return params.summary.selectedValues.join(", "); + } + return `${params.summary.selectedValues.slice(0, 3).join(", ")} +${ + params.summary.selectedValues.length - 3 + }`; + } + if (params.summary.selectedDate) { + return params.summary.selectedDate; + } + if (params.summary.selectedTime) { + return params.summary.selectedTime; + } + if (typeof params.summary.selectedDateTime === "number") { + return new Date(params.summary.selectedDateTime * 1000).toISOString(); + } + if (params.summary.richTextPreview) { + return params.summary.richTextPreview; + } + if (params.summary.value?.trim()) { + return params.summary.value.trim(); + } + return params.actionId; +} + +function formatInteractionConfirmationText(params: { + selectedLabel: string; + userId?: string; +}): string { + const actor = params.userId?.trim() ? ` by <@${params.userId.trim()}>` : ""; + return `:white_check_mark: *${escapeSlackMrkdwn(params.selectedLabel)}* selected${actor}`; +} + +function buildSlackPluginInteractionData(params: { + actionId: string; + summary: SlackActionSummary; +}): string | null { + const actionId = params.actionId.trim(); + if (!actionId) { + return null; + } + const payload = + params.summary.value?.trim() || + params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) || + ""; + if (actionId === SLACK_REPLY_BUTTON_ACTION_ID || actionId === SLACK_REPLY_SELECT_ACTION_ID) { + return payload || null; + } + return payload ? `${actionId}:${payload}` : actionId; +} + +function buildSlackPluginInteractionId(params: { + userId?: string; + channelId?: string; + messageTs?: string; + triggerId?: string; + actionId: string; + summary: SlackActionSummary; +}): string { + const primaryValue = + params.summary.value?.trim() || + params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) || + ""; + return [ + params.userId?.trim() || "", + params.channelId?.trim() || "", + params.messageTs?.trim() || "", + params.triggerId?.trim() || "", + params.actionId.trim(), + primaryValue, + ].join(":"); +} + +function parseSlackBlockAction(params: { + body: unknown; + action: unknown; + log?: (message: string) => void; +}): ParsedSlackBlockAction | null { + const typedBody = params.body as SlackBlockActionBody; + const typedAction = readInteractionAction(params.action); + if (!typedAction) { + params.log?.( + `slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${ + typedBody.user?.id ?? "unknown" + }`, + ); + return null; + } + const typedActionWithText = typedAction as { + action_id?: string; + block_id?: string; + type?: string; + text?: { text?: string }; + }; + return { + typedBody, + typedAction, + typedActionWithText, + actionId: + typeof typedActionWithText.action_id === "string" ? typedActionWithText.action_id : "unknown", + blockId: typedActionWithText.block_id, + userId: typedBody.user?.id ?? "unknown", + channelId: typedBody.channel?.id ?? typedBody.container?.channel_id, + messageTs: typedBody.message?.ts ?? typedBody.container?.message_ts, + threadTs: typedBody.container?.thread_ts, + actionSummary: summarizeAction(typedAction), + }; +} + +async function respondEphemeral( + respond: SlackBlockActionRespond | undefined, + text: string, +): Promise { + if (!respond) { + return; + } + try { + await respond({ + text, + response_type: "ephemeral", + }); + } catch { + // Best-effort feedback only. + } +} + +async function updateSlackInteractionMessage(params: { + ctx: SlackMonitorContext; + channelId?: string; + messageTs?: string; + text: string; + blocks?: (Block | KnownBlock)[]; +}): Promise { + if (!params.channelId || !params.messageTs) { + return; + } + await params.ctx.app.client.chat.update({ + channel: params.channelId, + ts: params.messageTs, + text: params.text, + ...(params.blocks ? { blocks: params.blocks } : {}), + }); +} + +async function authorizeSlackBlockAction(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + respond?: SlackBlockActionRespond; +}): Promise< + | { + allowed: true; + channelType?: "im" | "mpim" | "channel" | "group"; + } + | { allowed: false } +> { + const auth = await authorizeSlackSystemEventSender({ + ctx: params.ctx, + senderId: params.parsed.userId, + channelId: params.parsed.channelId, + }); + if (auth.allowed) { + return auth; + } + params.ctx.runtime.log?.( + `slack:interaction drop action=${params.parsed.actionId} user=${params.parsed.userId} channel=${params.parsed.channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, + ); + await respondEphemeral(params.respond, "You are not authorized to use this control."); + return { allowed: false }; +} + +async function handleSlackPluginBindingApproval(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + pluginInteractionData: string; + respond?: SlackBlockActionRespond; +}): Promise { + const pluginBindingApproval = parsePluginBindingApprovalCustomId(params.pluginInteractionData); + if (!pluginBindingApproval) { + return false; + } + const resolved = await resolvePluginConversationBindingApproval({ + approvalId: pluginBindingApproval.approvalId, + decision: pluginBindingApproval.decision, + senderId: params.parsed.userId, + }); + try { + await updateSlackInteractionMessage({ + ctx: params.ctx, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + text: params.parsed.typedBody.message?.text ?? "", + blocks: [], + }); + } catch { + // Best-effort cleanup only; continue with follow-up feedback. + } + await respondEphemeral(params.respond, buildPluginBindingResolvedText(resolved)); + return true; +} + +async function dispatchSlackPluginInteraction(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + pluginInteractionData: string; + auth: { isAuthorizedSender: boolean }; + respond?: SlackBlockActionRespond; +}): Promise { + const pluginInteractionId = buildSlackPluginInteractionId({ + userId: params.parsed.userId, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + triggerId: params.parsed.typedBody.trigger_id, + actionId: params.parsed.actionId, + summary: params.parsed.actionSummary, + }); + if ( + await handleSlackPluginBindingApproval({ + ctx: params.ctx, + parsed: params.parsed, + pluginInteractionData: params.pluginInteractionData, + respond: params.respond, + }) + ) { + return true; + } + const pluginResult = await dispatchPluginInteractiveHandler({ + channel: "slack", + data: params.pluginInteractionData, + interactionId: pluginInteractionId, + ctx: { + accountId: params.ctx.accountId, + interactionId: pluginInteractionId, + conversationId: params.parsed.channelId ?? "", + parentConversationId: undefined, + threadId: params.parsed.threadTs, + senderId: params.parsed.userId, + senderUsername: undefined, + auth: params.auth, + interaction: { + kind: params.parsed.actionSummary.actionType === "button" ? "button" : "select", + actionId: params.parsed.actionId, + blockId: params.parsed.blockId, + messageTs: params.parsed.messageTs, + threadTs: params.parsed.threadTs, + value: params.parsed.actionSummary.value, + selectedValues: params.parsed.actionSummary.selectedValues, + selectedLabels: params.parsed.actionSummary.selectedLabels, + triggerId: params.parsed.typedBody.trigger_id, + responseUrl: params.parsed.typedBody.response_url, + }, + }, + respond: { + acknowledge: async () => {}, + reply: async ({ text, responseType }) => { + if (!text) { + return; + } + await params.respond?.({ + text, + response_type: responseType ?? "ephemeral", + }); + }, + followUp: async ({ text, responseType }) => { + if (!text) { + return; + } + await params.respond?.({ + text, + response_type: responseType ?? "ephemeral", + }); + }, + editMessage: async ({ text, blocks }) => { + await updateSlackInteractionMessage({ + ctx: params.ctx, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + text: text ?? params.parsed.typedBody.message?.text ?? "", + blocks: Array.isArray(blocks) ? (blocks as (Block | KnownBlock)[]) : undefined, + }); + }, + }, + }); + return pluginResult.matched && pluginResult.handled; +} + +function enqueueSlackBlockActionEvent(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + auth: { channelType?: "im" | "mpim" | "channel" | "group" }; + formatSystemEvent: (payload: Record) => string; +}): void { + const eventPayload: InteractionSummary = { + interactionType: "block_action", + actionId: params.parsed.actionId, + blockId: params.parsed.blockId, + ...params.parsed.actionSummary, + userId: params.parsed.userId, + teamId: params.parsed.typedBody.team?.id, + triggerId: params.parsed.typedBody.trigger_id, + responseUrl: params.parsed.typedBody.response_url, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + threadTs: params.parsed.threadTs, + }; + params.ctx.runtime.log?.( + `slack:interaction action=${params.parsed.actionId} type=${params.parsed.actionSummary.actionType ?? "unknown"} user=${params.parsed.userId} channel=${params.parsed.channelId}`, + ); + const sessionKey = params.ctx.resolveSlackSystemEventSessionKey({ + channelId: params.parsed.channelId, + channelType: params.auth.channelType, + senderId: params.parsed.userId, + }); + const contextParts = [ + "slack:interaction", + params.parsed.channelId, + params.parsed.messageTs, + params.parsed.actionId, + ].filter(Boolean); + enqueueSystemEvent(params.formatSystemEvent(eventPayload), { + sessionKey, + contextKey: contextParts.join(":"), + }); +} + +function buildSlackConfirmationBlocks(params: { + parsed: ParsedSlackBlockAction; + originalBlocks: unknown[]; +}): (Block | KnownBlock)[] { + const selectedLabel = formatInteractionSelectionLabel({ + actionId: params.parsed.actionId, + summary: params.parsed.actionSummary, + buttonText: params.parsed.typedActionWithText.text?.text, + }); + let updatedBlocks = params.originalBlocks.map((block) => { + const typedBlock = block as InteractionMessageBlock; + if (typedBlock.type === "actions" && typedBlock.block_id === params.parsed.blockId) { + return { + type: "context", + elements: [ + { + type: "mrkdwn", + text: formatInteractionConfirmationText({ + selectedLabel, + userId: params.parsed.userId, + }), + }, + ], + }; + } + return block; + }); + const hasRemainingIndividualActionRows = updatedBlocks.some((block) => { + const typedBlock = block as InteractionMessageBlock; + return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock); + }); + if (!hasRemainingIndividualActionRows) { + updatedBlocks = updatedBlocks.filter((block, index) => { + const typedBlock = block as InteractionMessageBlock; + if (isBulkActionsBlock(typedBlock)) { + return false; + } + if (typedBlock.type !== "divider") { + return true; + } + const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined; + return !next || !isBulkActionsBlock(next); + }); + } + return updatedBlocks as (Block | KnownBlock)[]; +} + +async function updateSlackLegacyBlockAction(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + respond?: SlackBlockActionRespond; +}): Promise { + const originalBlocks = params.parsed.typedBody.message?.blocks; + if ( + !Array.isArray(originalBlocks) || + !params.parsed.channelId || + !params.parsed.messageTs || + !params.parsed.blockId + ) { + return; + } + try { + await updateSlackInteractionMessage({ + ctx: params.ctx, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + text: params.parsed.typedBody.message?.text ?? "", + blocks: buildSlackConfirmationBlocks({ + parsed: params.parsed, + originalBlocks, + }), + }); + } catch { + await respondEphemeral(params.respond, `Button "${params.parsed.actionId}" clicked!`); + } +} + +async function handleSlackBlockAction(params: { + ctx: SlackMonitorContext; + args: SlackActionMiddlewareArgs; + formatSystemEvent: (payload: Record) => string; +}): Promise { + const { ack, body, action, respond } = params.args; + await ack(); + if (params.ctx.shouldDropMismatchedSlackEvent?.(body)) { + params.ctx.runtime.log?.("slack:interaction drop block action payload (mismatched app/team)"); + return; + } + const parsed = parseSlackBlockAction({ + body, + action, + log: params.ctx.runtime.log, + }); + if (!parsed) { + return; + } + const auth = await authorizeSlackBlockAction({ + ctx: params.ctx, + parsed, + respond, + }); + if (!auth.allowed) { + return; + } + const pluginInteractionData = buildSlackPluginInteractionData({ + actionId: parsed.actionId, + summary: parsed.actionSummary, + }); + if (pluginInteractionData) { + const handled = await dispatchSlackPluginInteraction({ + ctx: params.ctx, + parsed, + pluginInteractionData, + auth: { + isAuthorizedSender: true, + }, + respond, + }); + if (handled) { + return; + } + } + enqueueSlackBlockActionEvent({ + ctx: params.ctx, + parsed, + auth, + formatSystemEvent: params.formatSystemEvent, + }); + await updateSlackLegacyBlockAction({ + ctx: params.ctx, + parsed, + respond, + }); +} + +export function registerSlackBlockActionHandler(params: { + ctx: SlackMonitorContext; + formatSystemEvent: (payload: Record) => string; +}): void { + if (typeof params.ctx.app.action !== "function") { + return; + } + params.ctx.app.action(/.+/, async (args: SlackActionMiddlewareArgs) => { + await handleSlackBlockAction({ + ctx: params.ctx, + args, + formatSystemEvent: params.formatSystemEvent, + }); + }); +} diff --git a/extensions/slack/src/monitor/events/interactions.ts b/extensions/slack/src/monitor/events/interactions.ts index 1ebb55d090e..384498ac5fe 100644 --- a/extensions/slack/src/monitor/events/interactions.ts +++ b/extensions/slack/src/monitor/events/interactions.ts @@ -1,17 +1,10 @@ -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"; -import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID } from "../../blocks-render.js"; import { truncateSlackText } from "../../truncate.js"; -import { authorizeSlackSystemEventSender } from "../auth.js"; import type { SlackMonitorContext } from "../context.js"; -import { escapeSlackMrkdwn } from "../mrkdwn.js"; +import { + registerSlackBlockActionHandler, + summarizeAction, + type InteractionSummary, +} from "./interactions.block-actions.js"; import { registerModalLifecycleHandler, type ModalInputSummary, @@ -34,33 +27,6 @@ const SLACK_INTERACTION_REDACTED_KEYS = new Set([ "viewHash", ]); -type InteractionMessageBlock = { - type?: string; - block_id?: string; - elements?: Array<{ action_id?: string }>; -}; - -type SelectOption = { - value?: string; - text?: { text?: string }; -}; - -type InteractionSelectionFields = Partial; - -type InteractionSummary = InteractionSelectionFields & { - interactionType?: "block_action" | "view_submission" | "view_closed"; - actionId: string; - userId?: string; - teamId?: string; - triggerId?: string; - responseUrl?: string; - workflowTriggerUrl?: string; - workflowId?: string; - channelId?: string; - messageTs?: string; - threadTs?: string; -}; - function sanitizeSlackInteractionPayloadValue(value: unknown, key?: string): unknown { if (value === undefined) { return undefined; @@ -189,281 +155,6 @@ function formatSlackInteractionSystemEvent(payload: Record): st }); } -function readOptionValues(options: unknown): string[] | undefined { - if (!Array.isArray(options)) { - return undefined; - } - const values = options - .map((option) => (option && typeof option === "object" ? (option as SelectOption).value : null)) - .filter((value): value is string => typeof value === "string" && value.trim().length > 0); - return values.length > 0 ? values : undefined; -} - -function readOptionLabels(options: unknown): string[] | undefined { - if (!Array.isArray(options)) { - return undefined; - } - const labels = options - .map((option) => - option && typeof option === "object" ? ((option as SelectOption).text?.text ?? null) : null, - ) - .filter((label): label is string => typeof label === "string" && label.trim().length > 0); - return labels.length > 0 ? labels : undefined; -} - -function uniqueNonEmptyStrings(values: string[]): string[] { - const unique: string[] = []; - const seen = new Set(); - for (const entry of values) { - if (typeof entry !== "string") { - continue; - } - const trimmed = entry.trim(); - if (!trimmed || seen.has(trimmed)) { - continue; - } - seen.add(trimmed); - unique.push(trimmed); - } - return unique; -} - -function collectRichTextFragments(value: unknown, out: string[]): void { - if (!value || typeof value !== "object") { - return; - } - const typed = value as { text?: unknown; elements?: unknown }; - if (typeof typed.text === "string" && typed.text.trim().length > 0) { - out.push(typed.text.trim()); - } - if (Array.isArray(typed.elements)) { - for (const child of typed.elements) { - collectRichTextFragments(child, out); - } - } -} - -function summarizeRichTextPreview(value: unknown): string | undefined { - const fragments: string[] = []; - collectRichTextFragments(value, fragments); - if (fragments.length === 0) { - return undefined; - } - const joined = fragments.join(" ").replace(/\s+/g, " ").trim(); - if (!joined) { - return undefined; - } - const max = 120; - return joined.length <= max ? joined : `${joined.slice(0, max - 1)}…`; -} - -function readInteractionAction(raw: unknown) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return undefined; - } - return raw as Record; -} - -function summarizeAction( - action: Record, -): Omit { - const typed = action as { - type?: string; - selected_option?: SelectOption; - selected_options?: SelectOption[]; - selected_user?: string; - selected_users?: string[]; - selected_channel?: string; - selected_channels?: string[]; - selected_conversation?: string; - selected_conversations?: string[]; - selected_date?: string; - selected_time?: string; - selected_date_time?: number; - value?: string; - rich_text_value?: unknown; - workflow?: { - trigger_url?: string; - workflow_id?: string; - }; - }; - const actionType = typed.type; - const selectedUsers = uniqueNonEmptyStrings([ - ...(typed.selected_user ? [typed.selected_user] : []), - ...(Array.isArray(typed.selected_users) ? typed.selected_users : []), - ]); - const selectedChannels = uniqueNonEmptyStrings([ - ...(typed.selected_channel ? [typed.selected_channel] : []), - ...(Array.isArray(typed.selected_channels) ? typed.selected_channels : []), - ]); - const selectedConversations = uniqueNonEmptyStrings([ - ...(typed.selected_conversation ? [typed.selected_conversation] : []), - ...(Array.isArray(typed.selected_conversations) ? typed.selected_conversations : []), - ]); - const selectedValues = uniqueNonEmptyStrings([ - ...(typed.selected_option?.value ? [typed.selected_option.value] : []), - ...(readOptionValues(typed.selected_options) ?? []), - ...selectedUsers, - ...selectedChannels, - ...selectedConversations, - ]); - const selectedLabels = uniqueNonEmptyStrings([ - ...(typed.selected_option?.text?.text ? [typed.selected_option.text.text] : []), - ...(readOptionLabels(typed.selected_options) ?? []), - ]); - const inputValue = typeof typed.value === "string" ? typed.value : undefined; - const inputNumber = - actionType === "number_input" && inputValue != null ? Number.parseFloat(inputValue) : undefined; - const parsedNumber = Number.isFinite(inputNumber) ? inputNumber : undefined; - const inputEmail = - actionType === "email_text_input" && inputValue?.includes("@") ? inputValue : undefined; - let inputUrl: string | undefined; - if (actionType === "url_text_input" && inputValue) { - try { - // Normalize to a canonical URL string so downstream handlers do not need to reparse. - inputUrl = new URL(inputValue).toString(); - } catch { - inputUrl = undefined; - } - } - const richTextValue = actionType === "rich_text_input" ? typed.rich_text_value : undefined; - const richTextPreview = summarizeRichTextPreview(richTextValue); - const inputKind = - actionType === "number_input" - ? "number" - : actionType === "email_text_input" - ? "email" - : actionType === "url_text_input" - ? "url" - : actionType === "rich_text_input" - ? "rich_text" - : inputValue != null - ? "text" - : undefined; - - return { - actionType, - inputKind, - value: typed.value, - selectedValues: selectedValues.length > 0 ? selectedValues : undefined, - selectedUsers: selectedUsers.length > 0 ? selectedUsers : undefined, - selectedChannels: selectedChannels.length > 0 ? selectedChannels : undefined, - selectedConversations: selectedConversations.length > 0 ? selectedConversations : undefined, - selectedLabels: selectedLabels.length > 0 ? selectedLabels : undefined, - selectedDate: typed.selected_date, - selectedTime: typed.selected_time, - selectedDateTime: - typeof typed.selected_date_time === "number" ? typed.selected_date_time : undefined, - inputValue, - inputNumber: parsedNumber, - inputEmail, - inputUrl, - richTextValue, - richTextPreview, - workflowTriggerUrl: typed.workflow?.trigger_url, - workflowId: typed.workflow?.workflow_id, - }; -} - -function isBulkActionsBlock(block: InteractionMessageBlock): boolean { - return ( - block.type === "actions" && - Array.isArray(block.elements) && - block.elements.length > 0 && - block.elements.every((el) => typeof el.action_id === "string" && el.action_id.includes("_all_")) - ); -} - -function formatInteractionSelectionLabel(params: { - actionId: string; - summary: Omit; - buttonText?: string; -}): string { - if (params.summary.actionType === "button" && params.buttonText?.trim()) { - return params.buttonText.trim(); - } - if (params.summary.selectedLabels?.length) { - if (params.summary.selectedLabels.length <= 3) { - return params.summary.selectedLabels.join(", "); - } - return `${params.summary.selectedLabels.slice(0, 3).join(", ")} +${ - params.summary.selectedLabels.length - 3 - }`; - } - if (params.summary.selectedValues?.length) { - if (params.summary.selectedValues.length <= 3) { - return params.summary.selectedValues.join(", "); - } - return `${params.summary.selectedValues.slice(0, 3).join(", ")} +${ - params.summary.selectedValues.length - 3 - }`; - } - if (params.summary.selectedDate) { - return params.summary.selectedDate; - } - if (params.summary.selectedTime) { - return params.summary.selectedTime; - } - if (typeof params.summary.selectedDateTime === "number") { - return new Date(params.summary.selectedDateTime * 1000).toISOString(); - } - if (params.summary.richTextPreview) { - return params.summary.richTextPreview; - } - if (params.summary.value?.trim()) { - return params.summary.value.trim(); - } - return params.actionId; -} - -function formatInteractionConfirmationText(params: { - selectedLabel: string; - userId?: string; -}): string { - const actor = params.userId?.trim() ? ` by <@${params.userId.trim()}>` : ""; - return `:white_check_mark: *${escapeSlackMrkdwn(params.selectedLabel)}* selected${actor}`; -} - -function buildSlackPluginInteractionData(params: { - actionId: string; - summary: Omit; -}): string | null { - const actionId = params.actionId.trim(); - if (!actionId) { - return null; - } - const payload = - params.summary.value?.trim() || - params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) || - ""; - if (actionId === SLACK_REPLY_BUTTON_ACTION_ID || actionId === SLACK_REPLY_SELECT_ACTION_ID) { - return payload || null; - } - return payload ? `${actionId}:${payload}` : actionId; -} - -function buildSlackPluginInteractionId(params: { - userId?: string; - channelId?: string; - messageTs?: string; - triggerId?: string; - actionId: string; - summary: Omit; -}): string { - const primaryValue = - params.summary.value?.trim() || - params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) || - ""; - return [ - params.userId?.trim() || "", - params.channelId?.trim() || "", - params.messageTs?.trim() || "", - params.triggerId?.trim() || "", - params.actionId.trim(), - primaryValue, - ].join(":"); -} - function summarizeViewState(values: unknown): ModalInputSummary[] { if (!values || typeof values !== "object") { return []; @@ -490,291 +181,9 @@ function summarizeViewState(values: unknown): ModalInputSummary[] { export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContext }) { const { ctx } = params; - if (typeof ctx.app.action !== "function") { - return; - } - - // Handle Block Kit actions for this Slack app, including legacy/custom - // action_ids that plugin handlers map into shared interactive namespaces. - ctx.app.action(/.+/, async (args: SlackActionMiddlewareArgs) => { - const { ack, body, action, respond } = args; - const typedBody = body as unknown as { - user?: { id?: string }; - team?: { id?: string }; - trigger_id?: string; - response_url?: string; - channel?: { id?: string }; - container?: { channel_id?: string; message_ts?: string; thread_ts?: string }; - message?: { ts?: string; text?: string; blocks?: unknown[] }; - }; - - // Acknowledge the action immediately to prevent the warning icon - await ack(); - if (ctx.shouldDropMismatchedSlackEvent?.(body)) { - ctx.runtime.log?.("slack:interaction drop block action payload (mismatched app/team)"); - return; - } - - // Extract action details using proper Bolt types - const typedAction = readInteractionAction(action); - if (!typedAction) { - ctx.runtime.log?.( - `slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${ - typedBody.user?.id ?? "unknown" - }`, - ); - return; - } - const typedActionWithText = typedAction as { - action_id?: string; - block_id?: string; - type?: string; - text?: { text?: string }; - }; - const actionId = - typeof typedActionWithText.action_id === "string" ? typedActionWithText.action_id : "unknown"; - const blockId = typedActionWithText.block_id; - const userId = typedBody.user?.id ?? "unknown"; - const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id; - const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts; - const threadTs = typedBody.container?.thread_ts; - const auth = await authorizeSlackSystemEventSender({ - ctx, - senderId: userId, - channelId, - }); - if (!auth.allowed) { - ctx.runtime.log?.( - `slack:interaction drop action=${actionId} user=${userId} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, - ); - if (respond) { - try { - await respond({ - text: "You are not authorized to use this control.", - response_type: "ephemeral", - }); - } catch { - // Best-effort feedback only. - } - } - return; - } - const actionSummary = summarizeAction(typedAction); - const pluginInteractionData = buildSlackPluginInteractionData({ - actionId, - summary: actionSummary, - }); - if (pluginInteractionData) { - const pluginInteractionId = buildSlackPluginInteractionId({ - userId, - channelId, - messageTs, - triggerId: typedBody.trigger_id, - actionId, - summary: actionSummary, - }); - const pluginBindingApproval = parsePluginBindingApprovalCustomId(pluginInteractionData); - if (pluginBindingApproval) { - const resolved = await resolvePluginConversationBindingApproval({ - approvalId: pluginBindingApproval.approvalId, - decision: pluginBindingApproval.decision, - senderId: userId, - }); - if (channelId && messageTs) { - try { - await ctx.app.client.chat.update({ - channel: channelId, - ts: messageTs, - text: typedBody.message?.text ?? "", - blocks: [], - }); - } catch { - // Best-effort cleanup only; continue with follow-up feedback. - } - } - if (respond) { - try { - await respond({ - text: buildPluginBindingResolvedText(resolved), - response_type: "ephemeral", - }); - } catch { - // Best-effort feedback only. - } - } - return; - } - const pluginResult = await dispatchPluginInteractiveHandler({ - channel: "slack", - data: pluginInteractionData, - interactionId: pluginInteractionId, - ctx: { - accountId: ctx.accountId, - interactionId: pluginInteractionId, - conversationId: channelId ?? "", - parentConversationId: undefined, - threadId: threadTs, - senderId: userId, - senderUsername: undefined, - auth: { - isAuthorizedSender: auth.allowed, - }, - interaction: { - kind: actionSummary.actionType === "button" ? "button" : "select", - actionId, - blockId, - messageTs, - threadTs, - value: actionSummary.value, - selectedValues: actionSummary.selectedValues, - selectedLabels: actionSummary.selectedLabels, - triggerId: typedBody.trigger_id, - responseUrl: typedBody.response_url, - }, - }, - respond: { - acknowledge: async () => {}, - reply: async ({ text, responseType }) => { - if (!respond) { - return; - } - await respond({ - text, - response_type: responseType ?? "ephemeral", - }); - }, - followUp: async ({ text, responseType }) => { - if (!respond) { - return; - } - await respond({ - text, - response_type: responseType ?? "ephemeral", - }); - }, - editMessage: async ({ text, blocks }) => { - if (!channelId || !messageTs) { - return; - } - await ctx.app.client.chat.update({ - channel: channelId, - ts: messageTs, - text: text ?? typedBody.message?.text ?? "", - ...(Array.isArray(blocks) ? { blocks: blocks as (Block | KnownBlock)[] } : {}), - }); - }, - }, - }); - if (pluginResult.matched && pluginResult.handled) { - return; - } - } - const eventPayload: InteractionSummary = { - interactionType: "block_action", - actionId, - blockId, - ...actionSummary, - userId, - teamId: typedBody.team?.id, - triggerId: typedBody.trigger_id, - responseUrl: typedBody.response_url, - channelId, - messageTs, - threadTs, - }; - - // Log the interaction for debugging - ctx.runtime.log?.( - `slack:interaction action=${actionId} type=${actionSummary.actionType ?? "unknown"} user=${userId} channel=${channelId}`, - ); - - // Send a system event to notify the agent about the button click - // Pass undefined (not "unknown") to allow proper main session fallback - const sessionKey = ctx.resolveSlackSystemEventSessionKey({ - channelId: channelId, - channelType: auth.channelType, - senderId: userId, - }); - - // Build context key - only include defined values to avoid "unknown" noise - const contextParts = ["slack:interaction", channelId, messageTs, actionId].filter(Boolean); - const contextKey = contextParts.join(":"); - - enqueueSystemEvent(formatSlackInteractionSystemEvent(eventPayload), { - sessionKey, - contextKey, - }); - - const originalBlocks = typedBody.message?.blocks; - if (!Array.isArray(originalBlocks) || !channelId || !messageTs) { - return; - } - - if (!blockId) { - return; - } - - const selectedLabel = formatInteractionSelectionLabel({ - actionId, - summary: actionSummary, - buttonText: typedActionWithText.text?.text, - }); - let updatedBlocks = originalBlocks.map((block) => { - const typedBlock = block as InteractionMessageBlock; - if (typedBlock.type === "actions" && typedBlock.block_id === blockId) { - return { - type: "context", - elements: [ - { - type: "mrkdwn", - text: formatInteractionConfirmationText({ selectedLabel, userId }), - }, - ], - }; - } - return block; - }); - - const hasRemainingIndividualActionRows = updatedBlocks.some((block) => { - const typedBlock = block as InteractionMessageBlock; - return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock); - }); - - if (!hasRemainingIndividualActionRows) { - updatedBlocks = updatedBlocks.filter((block, index) => { - const typedBlock = block as InteractionMessageBlock; - if (isBulkActionsBlock(typedBlock)) { - return false; - } - if (typedBlock.type !== "divider") { - return true; - } - const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined; - return !next || !isBulkActionsBlock(next); - }); - } - - try { - await ctx.app.client.chat.update({ - channel: channelId, - ts: messageTs, - text: typedBody.message?.text ?? "", - blocks: updatedBlocks as (Block | KnownBlock)[], - }); - } catch { - // If update fails, fallback to ephemeral confirmation for immediate UX feedback. - if (!respond) { - return; - } - try { - await respond({ - text: `Button "${actionId}" clicked!`, - response_type: "ephemeral", - }); - } catch { - // Action was acknowledged and system event enqueued even when response updates fail. - } - } + registerSlackBlockActionHandler({ + ctx, + formatSystemEvent: formatSlackInteractionSystemEvent, }); if (typeof ctx.app.view !== "function") { From 39634088712e5f8530d9063cf5b413c5bf496a18 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:28:08 +0000 Subject: [PATCH 017/133] refactor: split plugin interactive dispatch adapters --- src/plugins/interactive-dispatch-adapters.ts | 219 ++++++++++ src/plugins/interactive.test.ts | 404 ++++++++++++++++++- src/plugins/interactive.ts | 276 ++----------- 3 files changed, 650 insertions(+), 249 deletions(-) create mode 100644 src/plugins/interactive-dispatch-adapters.ts diff --git a/src/plugins/interactive-dispatch-adapters.ts b/src/plugins/interactive-dispatch-adapters.ts new file mode 100644 index 00000000000..4050e707958 --- /dev/null +++ b/src/plugins/interactive-dispatch-adapters.ts @@ -0,0 +1,219 @@ +import { + detachPluginConversationBinding, + getCurrentPluginConversationBinding, + requestPluginConversationBinding, +} from "./conversation-binding.js"; +import type { + PluginConversationBindingRequestParams, + PluginInteractiveDiscordHandlerContext, + PluginInteractiveDiscordHandlerRegistration, + PluginInteractiveSlackHandlerContext, + PluginInteractiveSlackHandlerRegistration, + PluginInteractiveTelegramHandlerContext, + PluginInteractiveTelegramHandlerRegistration, +} from "./types.js"; + +type RegisteredInteractiveMetadata = { + pluginId: string; + pluginName?: string; + pluginRoot?: string; +}; + +type PluginBindingConversation = Parameters< + typeof requestPluginConversationBinding +>[0]["conversation"]; + +export type TelegramInteractiveDispatchContext = Omit< + PluginInteractiveTelegramHandlerContext, + | "callback" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + callbackMessage: { + messageId: number; + chatId: string; + messageText?: string; + }; +}; + +export type DiscordInteractiveDispatchContext = Omit< + PluginInteractiveDiscordHandlerContext, + | "interaction" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + interaction: Omit< + PluginInteractiveDiscordHandlerContext["interaction"], + "data" | "namespace" | "payload" + >; +}; + +export type SlackInteractiveDispatchContext = Omit< + PluginInteractiveSlackHandlerContext, + | "interaction" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + interaction: Omit< + PluginInteractiveSlackHandlerContext["interaction"], + "data" | "namespace" | "payload" + >; +}; + +function createConversationBindingHelpers(params: { + registration: RegisteredInteractiveMetadata; + senderId?: string; + conversation: PluginBindingConversation; +}) { + const { registration, senderId, conversation } = params; + const pluginRoot = registration.pluginRoot; + + return { + requestConversationBinding: async (binding: PluginConversationBindingRequestParams = {}) => { + if (!pluginRoot) { + return { + status: "error" as const, + message: "This interaction cannot bind the current conversation.", + }; + } + return requestPluginConversationBinding({ + pluginId: registration.pluginId, + pluginName: registration.pluginName, + pluginRoot, + requestedBySenderId: senderId, + conversation, + binding, + }); + }, + detachConversationBinding: async () => { + if (!pluginRoot) { + return { removed: false }; + } + return detachPluginConversationBinding({ + pluginRoot, + conversation, + }); + }, + getCurrentConversationBinding: async () => { + if (!pluginRoot) { + return null; + } + return getCurrentPluginConversationBinding({ + pluginRoot, + conversation, + }); + }, + }; +} + +export function dispatchTelegramInteractiveHandler(params: { + registration: PluginInteractiveTelegramHandlerRegistration & RegisteredInteractiveMetadata; + data: string; + namespace: string; + payload: string; + ctx: TelegramInteractiveDispatchContext; + respond: PluginInteractiveTelegramHandlerContext["respond"]; +}) { + const { callbackMessage, ...handlerContext } = params.ctx; + + return params.registration.handler({ + ...handlerContext, + channel: "telegram", + callback: { + data: params.data, + namespace: params.namespace, + payload: params.payload, + messageId: callbackMessage.messageId, + chatId: callbackMessage.chatId, + messageText: callbackMessage.messageText, + }, + respond: params.respond, + ...createConversationBindingHelpers({ + registration: params.registration, + senderId: handlerContext.senderId, + conversation: { + channel: "telegram", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + threadId: handlerContext.threadId, + }, + }), + }); +} + +export function dispatchDiscordInteractiveHandler(params: { + registration: PluginInteractiveDiscordHandlerRegistration & RegisteredInteractiveMetadata; + data: string; + namespace: string; + payload: string; + ctx: DiscordInteractiveDispatchContext; + respond: PluginInteractiveDiscordHandlerContext["respond"]; +}) { + const handlerContext = params.ctx; + + return params.registration.handler({ + ...handlerContext, + channel: "discord", + interaction: { + ...handlerContext.interaction, + data: params.data, + namespace: params.namespace, + payload: params.payload, + }, + respond: params.respond, + ...createConversationBindingHelpers({ + registration: params.registration, + senderId: handlerContext.senderId, + conversation: { + channel: "discord", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + }, + }), + }); +} + +export function dispatchSlackInteractiveHandler(params: { + registration: PluginInteractiveSlackHandlerRegistration & RegisteredInteractiveMetadata; + data: string; + namespace: string; + payload: string; + ctx: SlackInteractiveDispatchContext; + respond: PluginInteractiveSlackHandlerContext["respond"]; +}) { + const handlerContext = params.ctx; + + return params.registration.handler({ + ...handlerContext, + channel: "slack", + interaction: { + ...handlerContext.interaction, + data: params.data, + namespace: params.namespace, + payload: params.payload, + }, + respond: params.respond, + ...createConversationBindingHelpers({ + registration: params.registration, + senderId: handlerContext.senderId, + conversation: { + channel: "slack", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + threadId: handlerContext.threadId, + }, + }), + }); +} diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 10caf6dbfa9..14980ec4545 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -1,13 +1,62 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; +import * as conversationBinding from "./conversation-binding.js"; import { clearPluginInteractiveHandlers, dispatchPluginInteractiveHandler, registerPluginInteractiveHandler, } from "./interactive.js"; +let requestPluginConversationBindingMock: MockInstance< + typeof conversationBinding.requestPluginConversationBinding +>; +let detachPluginConversationBindingMock: MockInstance< + typeof conversationBinding.detachPluginConversationBinding +>; +let getCurrentPluginConversationBindingMock: MockInstance< + typeof conversationBinding.getCurrentPluginConversationBinding +>; + describe("plugin interactive handlers", () => { beforeEach(() => { clearPluginInteractiveHandlers(); + requestPluginConversationBindingMock = vi + .spyOn(conversationBinding, "requestPluginConversationBinding") + .mockResolvedValue({ + status: "bound", + binding: { + bindingId: "binding-1", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + boundAt: 1, + }, + }); + detachPluginConversationBindingMock = vi + .spyOn(conversationBinding, "detachPluginConversationBinding") + .mockResolvedValue({ removed: true }); + getCurrentPluginConversationBindingMock = vi + .spyOn(conversationBinding, "getCurrentPluginConversationBinding") + .mockResolvedValue({ + bindingId: "binding-1", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + boundAt: 1, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it("routes Telegram callbacks by namespace and dedupes callback ids", async () => { @@ -213,6 +262,359 @@ describe("plugin interactive handlers", () => { ); }); + it("wires Telegram conversation binding helpers with topic context", async () => { + const requestResult = { + status: "bound" as const, + binding: { + bindingId: "binding-telegram", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + boundAt: 1, + }, + }; + const currentBinding = { + ...requestResult.binding, + boundAt: 2, + }; + requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult); + getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding); + + const handler = vi.fn(async (ctx) => { + await expect( + ctx.requestConversationBinding({ + summary: "Bind this topic", + detachHint: "Use /new to detach", + }), + ).resolves.toEqual(requestResult); + await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true }); + await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding); + return { handled: true }; + }); + expect( + registerPluginInteractiveHandler( + "codex-plugin", + { + channel: "telegram", + namespace: "codex", + handler, + }, + { pluginName: "Codex", pluginRoot: "/plugins/codex" }, + ), + ).toEqual({ ok: true }); + + await expect( + dispatchPluginInteractiveHandler({ + channel: "telegram", + data: "codex:bind", + callbackId: "cb-bind", + ctx: { + accountId: "default", + callbackId: "cb-bind", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + senderId: "user-1", + senderUsername: "ada", + threadId: 77, + isGroup: true, + isForum: true, + auth: { isAuthorizedSender: true }, + callbackMessage: { + messageId: 55, + chatId: "-10099", + messageText: "Pick a thread", + }, + }, + respond: { + reply: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + editButtons: vi.fn(async () => {}), + clearButtons: vi.fn(async () => {}), + deleteMessage: vi.fn(async () => {}), + }, + }), + ).resolves.toEqual({ + matched: true, + handled: true, + duplicate: false, + }); + + expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + }, + binding: { + summary: "Bind this topic", + detachHint: "Use /new to detach", + }, + }); + expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + }, + }); + expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + }, + }); + }); + + it("wires Discord conversation binding helpers with parent channel context", async () => { + const requestResult = { + status: "bound" as const, + binding: { + bindingId: "binding-discord", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + channel: "discord", + accountId: "default", + conversationId: "channel-1", + parentConversationId: "parent-1", + boundAt: 1, + }, + }; + const currentBinding = { + ...requestResult.binding, + boundAt: 2, + }; + requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult); + getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding); + + const handler = vi.fn(async (ctx) => { + await expect(ctx.requestConversationBinding({ summary: "Bind Discord" })).resolves.toEqual( + requestResult, + ); + await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true }); + await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding); + return { handled: true }; + }); + expect( + registerPluginInteractiveHandler( + "codex-plugin", + { + channel: "discord", + namespace: "codex", + handler, + }, + { pluginName: "Codex", pluginRoot: "/plugins/codex" }, + ), + ).toEqual({ ok: true }); + + await expect( + dispatchPluginInteractiveHandler({ + channel: "discord", + data: "codex:bind", + interactionId: "ix-bind", + ctx: { + accountId: "default", + interactionId: "ix-bind", + conversationId: "channel-1", + parentConversationId: "parent-1", + guildId: "guild-1", + senderId: "user-1", + senderUsername: "ada", + auth: { isAuthorizedSender: true }, + interaction: { + kind: "button", + messageId: "message-1", + values: ["allow"], + }, + }, + respond: { + acknowledge: vi.fn(async () => {}), + reply: vi.fn(async () => {}), + followUp: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + clearComponents: vi.fn(async () => {}), + }, + }), + ).resolves.toEqual({ + matched: true, + handled: true, + duplicate: false, + }); + + expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel-1", + parentConversationId: "parent-1", + }, + binding: { + summary: "Bind Discord", + }, + }); + expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel-1", + parentConversationId: "parent-1", + }, + }); + expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel-1", + parentConversationId: "parent-1", + }, + }); + }); + + it("wires Slack conversation binding helpers with thread context", async () => { + const requestResult = { + status: "bound" as const, + binding: { + bindingId: "binding-slack", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + channel: "slack", + accountId: "default", + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + boundAt: 1, + }, + }; + const currentBinding = { + ...requestResult.binding, + boundAt: 2, + }; + requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult); + getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding); + + const handler = vi.fn(async (ctx) => { + await expect(ctx.requestConversationBinding({ summary: "Bind Slack" })).resolves.toEqual( + requestResult, + ); + await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true }); + await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding); + return { handled: true }; + }); + expect( + registerPluginInteractiveHandler( + "codex-plugin", + { + channel: "slack", + namespace: "codex", + handler, + }, + { pluginName: "Codex", pluginRoot: "/plugins/codex" }, + ), + ).toEqual({ ok: true }); + + await expect( + dispatchPluginInteractiveHandler({ + channel: "slack", + data: "codex:bind", + interactionId: "slack-bind", + ctx: { + accountId: "default", + interactionId: "slack-bind", + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + senderId: "user-1", + senderUsername: "ada", + auth: { isAuthorizedSender: true }, + interaction: { + kind: "button", + actionId: "codex", + blockId: "codex_actions", + messageTs: "1710000000.000200", + threadTs: "1710000000.000100", + value: "bind", + selectedValues: ["bind"], + selectedLabels: ["Bind"], + triggerId: "trigger-1", + responseUrl: "https://hooks.slack.test/response", + }, + }, + respond: { + acknowledge: vi.fn(async () => {}), + reply: vi.fn(async () => {}), + followUp: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + }, + }), + ).resolves.toEqual({ + matched: true, + handled: true, + duplicate: false, + }); + + expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + requestedBySenderId: "user-1", + conversation: { + channel: "slack", + accountId: "default", + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + }, + binding: { + summary: "Bind Slack", + }, + }); + expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: { + channel: "slack", + accountId: "default", + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + }, + }); + expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: { + channel: "slack", + accountId: "default", + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + }, + }); + }); + it("does not consume dedupe keys when a handler throws", async () => { const handler = vi .fn(async () => ({ handled: true })) diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index 15561a8af15..04403c80fa2 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -1,9 +1,12 @@ import { createDedupeCache } from "../infra/dedupe.js"; import { - detachPluginConversationBinding, - getCurrentPluginConversationBinding, - requestPluginConversationBinding, -} from "./conversation-binding.js"; + dispatchDiscordInteractiveHandler, + dispatchSlackInteractiveHandler, + dispatchTelegramInteractiveHandler, + type DiscordInteractiveDispatchContext, + type SlackInteractiveDispatchContext, + type TelegramInteractiveDispatchContext, +} from "./interactive-dispatch-adapters.js"; import type { PluginInteractiveDiscordHandlerContext, PluginInteractiveButtons, @@ -30,52 +33,6 @@ type InteractiveDispatchResult = | { matched: false; handled: false; duplicate: false } | { matched: true; handled: boolean; duplicate: boolean }; -type TelegramInteractiveDispatchContext = Omit< - PluginInteractiveTelegramHandlerContext, - | "callback" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" -> & { - callbackMessage: { - messageId: number; - chatId: string; - messageText?: string; - }; -}; - -type DiscordInteractiveDispatchContext = Omit< - PluginInteractiveDiscordHandlerContext, - | "interaction" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" -> & { - interaction: Omit< - PluginInteractiveDiscordHandlerContext["interaction"], - "data" | "namespace" | "payload" - >; -}; - -type SlackInteractiveDispatchContext = Omit< - PluginInteractiveSlackHandlerContext, - | "interaction" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" -> & { - interaction: Omit< - PluginInteractiveSlackHandlerContext["interaction"], - "data" | "namespace" | "payload" - >; -}; - const interactiveHandlers = new Map(); const callbackDedupe = createDedupeCache({ ttlMs: 5 * 60_000, @@ -252,211 +209,34 @@ export async function dispatchPluginInteractiveHandler(params: { | ReturnType | ReturnType; if (params.channel === "telegram") { - const pluginRoot = match.registration.pluginRoot; - const { callbackMessage, ...handlerContext } = params.ctx as TelegramInteractiveDispatchContext; - result = ( - match.registration as RegisteredInteractiveHandler & - PluginInteractiveTelegramHandlerRegistration - ).handler({ - ...handlerContext, - channel: "telegram", - callback: { - data: params.data, - namespace: match.namespace, - payload: match.payload, - messageId: callbackMessage.messageId, - chatId: callbackMessage.chatId, - messageText: callbackMessage.messageText, - }, + result = dispatchTelegramInteractiveHandler({ + registration: match.registration as RegisteredInteractiveHandler & + PluginInteractiveTelegramHandlerRegistration, + data: params.data, + namespace: match.namespace, + payload: match.payload, + ctx: params.ctx as TelegramInteractiveDispatchContext, respond: params.respond as PluginInteractiveTelegramHandlerContext["respond"], - requestConversationBinding: async (bindingParams) => { - if (!pluginRoot) { - return { - status: "error", - message: "This interaction cannot bind the current conversation.", - }; - } - return requestPluginConversationBinding({ - pluginId: match.registration.pluginId, - pluginName: match.registration.pluginName, - pluginRoot, - requestedBySenderId: handlerContext.senderId, - conversation: { - channel: "telegram", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - threadId: handlerContext.threadId, - }, - binding: bindingParams, - }); - }, - detachConversationBinding: async () => { - if (!pluginRoot) { - return { removed: false }; - } - return detachPluginConversationBinding({ - pluginRoot, - conversation: { - channel: "telegram", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - threadId: handlerContext.threadId, - }, - }); - }, - getCurrentConversationBinding: async () => { - if (!pluginRoot) { - return null; - } - return getCurrentPluginConversationBinding({ - pluginRoot, - conversation: { - channel: "telegram", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - threadId: handlerContext.threadId, - }, - }); - }, }); } else if (params.channel === "discord") { - const pluginRoot = match.registration.pluginRoot; - result = ( - match.registration as RegisteredInteractiveHandler & - PluginInteractiveDiscordHandlerRegistration - ).handler({ - ...(params.ctx as DiscordInteractiveDispatchContext), - channel: "discord", - interaction: { - ...(params.ctx as DiscordInteractiveDispatchContext).interaction, - data: params.data, - namespace: match.namespace, - payload: match.payload, - }, + result = dispatchDiscordInteractiveHandler({ + registration: match.registration as RegisteredInteractiveHandler & + PluginInteractiveDiscordHandlerRegistration, + data: params.data, + namespace: match.namespace, + payload: match.payload, + ctx: params.ctx as DiscordInteractiveDispatchContext, respond: params.respond as PluginInteractiveDiscordHandlerContext["respond"], - requestConversationBinding: async (bindingParams) => { - if (!pluginRoot) { - return { - status: "error", - message: "This interaction cannot bind the current conversation.", - }; - } - const handlerContext = params.ctx as DiscordInteractiveDispatchContext; - return requestPluginConversationBinding({ - pluginId: match.registration.pluginId, - pluginName: match.registration.pluginName, - pluginRoot, - requestedBySenderId: handlerContext.senderId, - conversation: { - channel: "discord", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - }, - binding: bindingParams, - }); - }, - detachConversationBinding: async () => { - if (!pluginRoot) { - return { removed: false }; - } - const handlerContext = params.ctx as DiscordInteractiveDispatchContext; - return detachPluginConversationBinding({ - pluginRoot, - conversation: { - channel: "discord", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - }, - }); - }, - getCurrentConversationBinding: async () => { - if (!pluginRoot) { - return null; - } - const handlerContext = params.ctx as DiscordInteractiveDispatchContext; - return getCurrentPluginConversationBinding({ - pluginRoot, - conversation: { - channel: "discord", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - }, - }); - }, }); } else { - const pluginRoot = match.registration.pluginRoot; - const handlerContext = params.ctx as SlackInteractiveDispatchContext; - result = ( - match.registration as RegisteredInteractiveHandler & PluginInteractiveSlackHandlerRegistration - ).handler({ - ...handlerContext, - channel: "slack", - interaction: { - ...handlerContext.interaction, - data: params.data, - namespace: match.namespace, - payload: match.payload, - }, + result = dispatchSlackInteractiveHandler({ + registration: match.registration as RegisteredInteractiveHandler & + PluginInteractiveSlackHandlerRegistration, + data: params.data, + namespace: match.namespace, + payload: match.payload, + ctx: params.ctx as SlackInteractiveDispatchContext, respond: params.respond as PluginInteractiveSlackHandlerContext["respond"], - requestConversationBinding: async (bindingParams) => { - if (!pluginRoot) { - return { - status: "error", - message: "This interaction cannot bind the current conversation.", - }; - } - return requestPluginConversationBinding({ - pluginId: match.registration.pluginId, - pluginName: match.registration.pluginName, - pluginRoot, - requestedBySenderId: handlerContext.senderId, - conversation: { - channel: "slack", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - threadId: handlerContext.threadId, - }, - binding: bindingParams, - }); - }, - detachConversationBinding: async () => { - if (!pluginRoot) { - return { removed: false }; - } - return detachPluginConversationBinding({ - pluginRoot, - conversation: { - channel: "slack", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - threadId: handlerContext.threadId, - }, - }); - }, - getCurrentConversationBinding: async () => { - if (!pluginRoot) { - return null; - } - return getCurrentPluginConversationBinding({ - pluginRoot, - conversation: { - channel: "slack", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - threadId: handlerContext.threadId, - }, - }); - }, }); } const resolved = await result; From 7bea559166811732237db003c9ddc7e7e51f8c8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:32:41 +0000 Subject: [PATCH 018/133] refactor: unify reply content checks --- src/auto-reply/reply/normalize-reply.ts | 57 ++++++++++++++---- src/auto-reply/reply/reply-payloads.ts | 17 +++--- src/auto-reply/reply/route-reply.ts | 11 +++- src/infra/outbound/deliver.ts | 32 +++++----- src/infra/outbound/message-action-runner.ts | 18 +++--- src/infra/outbound/payloads.ts | 20 +++++-- src/interactive/payload.test.ts | 66 +++++++++++++++++++++ src/interactive/payload.ts | 24 ++++++++ 8 files changed, 195 insertions(+), 50 deletions(-) create mode 100644 src/interactive/payload.test.ts diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 9ef5a7a9d90..52faa463bdb 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -1,4 +1,5 @@ import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers.js"; +import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import { HEARTBEAT_TOKEN, @@ -31,13 +32,17 @@ export function normalizeReplyPayload( payload: ReplyPayload, opts: NormalizeReplyOptions = {}, ): ReplyPayload | null { - const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0); - const hasInteractive = (payload.interactive?.blocks.length ?? 0) > 0; - const hasChannelData = Boolean( - payload.channelData && Object.keys(payload.channelData).length > 0, - ); + const hasChannelData = hasReplyChannelData(payload.channelData); const trimmed = payload.text?.trim() ?? ""; - if (!trimmed && !hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text: trimmed, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("empty"); return null; } @@ -45,7 +50,14 @@ export function normalizeReplyPayload( const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN; let text = payload.text ?? undefined; if (text && isSilentReplyText(text, silentToken)) { - if (!hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("silent"); return null; } @@ -56,7 +68,15 @@ export function normalizeReplyPayload( // silent just like the exact-match path above. (#30916, #30955) if (text && text.includes(silentToken) && !isSilentReplyText(text, silentToken)) { text = stripSilentToken(text, silentToken); - if (!text && !hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("silent"); return null; } @@ -72,7 +92,16 @@ export function normalizeReplyPayload( if (stripped.didStrip) { opts.onHeartbeatStrip?.(); } - if (stripped.shouldSkip && !hasMedia && !hasInteractive && !hasChannelData) { + if ( + stripped.shouldSkip && + !hasReplyContent({ + text: stripped.text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("heartbeat"); return null; } @@ -82,7 +111,15 @@ export function normalizeReplyPayload( if (text) { text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) }); } - if (!text?.trim() && !hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("empty"); return null; } diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index ab7586f1664..f5f409e2900 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -4,6 +4,7 @@ import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js"; import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { ReplyToMode } from "../../config/types.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; +import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -74,14 +75,14 @@ export function applyReplyTagsToPayload( } export function isRenderablePayload(payload: ReplyPayload): boolean { - return Boolean( - payload.text || - payload.mediaUrl || - (payload.mediaUrls && payload.mediaUrls.length > 0) || - payload.audioAsVoice || - payload.interactive || - payload.channelData, - ); + return hasReplyContent({ + text: payload.text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData: hasReplyChannelData(payload.channelData), + extraContent: payload.audioAsVoice, + }); } export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 8dc7499526a..3836ceb5ab6 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -12,6 +12,7 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; +import { hasReplyContent } from "../../interactive/payload.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -119,13 +120,19 @@ export async function routeReply(params: RouteReplyParams): Promise 0; const hasChannelData = plugin?.messaging?.hasStructuredReplyPayload?.({ payload: externalPayload, }); // Skip empty replies. - if (!text.trim() && mediaUrls.length === 0 && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text, + mediaUrls, + interactive: externalPayload.interactive, + hasChannelData, + }) + ) { return { ok: true }; } diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 9d661b38c45..9e10f525cb0 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -30,6 +30,7 @@ import { toPluginMessageContext, toPluginMessageSentEvent, } from "../../hooks/message-hook-mappers.js"; +import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; @@ -238,30 +239,24 @@ type MessageSentEvent = { messageId?: string; }; -function hasMediaPayload(payload: ReplyPayload): boolean { - return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; -} - -function hasChannelDataPayload(payload: ReplyPayload): boolean { - return Boolean(payload.channelData && Object.keys(payload.channelData).length > 0); -} - -function hasInteractivePayload(payload: ReplyPayload): boolean { - return (payload.interactive?.blocks.length ?? 0) > 0; -} - function normalizePayloadForChannelDelivery( payload: ReplyPayload, channelId: string, ): ReplyPayload | null { - const hasMedia = hasMediaPayload(payload); - const hasChannelData = hasChannelDataPayload(payload); - const hasInteractive = hasInteractivePayload(payload); + const hasChannelData = hasReplyChannelData(payload.channelData); const rawText = typeof payload.text === "string" ? payload.text : ""; const normalizedText = channelId === "whatsapp" ? rawText.replace(/^(?:[ \t]*\r?\n)+/, "") : rawText; if (!normalizedText.trim()) { - if (!hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text: normalizedText, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { return null; } return { @@ -713,7 +708,10 @@ async function deliverOutboundPayloadsCore( }; if ( handler.sendPayload && - (effectivePayload.channelData || hasInteractivePayload(effectivePayload)) + (effectivePayload.channelData || + hasReplyContent({ + interactive: effectivePayload.interactive, + })) ) { const delivery = await handler.sendPayload(effectivePayload, sendOverrides); results.push(delivery); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index aa53f7398f4..8480b962544 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -14,6 +14,7 @@ import type { ChannelThreadingToolContext, } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { hasInteractiveReplyBlocks, hasReplyContent } from "../../interactive/payload.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { hasPollCreationParams, resolveTelegramPollVisibility } from "../../poll-params.js"; import { resolvePollMaxSelections } from "../../polls.js"; @@ -407,7 +408,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise 0; const hasCard = params.card != null && typeof params.card === "object"; const hasComponents = params.components != null && typeof params.components === "object"; - const hasInteractive = params.interactive != null && typeof params.interactive === "object"; + const hasInteractive = hasInteractiveReplyBlocks(params.interactive); const hasBlocks = (Array.isArray(params.blocks) && params.blocks.length > 0) || (typeof params.blocks === "string" && params.blocks.trim().length > 0); @@ -482,14 +483,13 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise 0); - const hasInteractive = Boolean(interactive?.blocks.length); + const hasChannelData = hasReplyChannelData(channelData); + const hasInteractive = hasInteractiveReplyBlocks(interactive); const text = payload.text ?? ""; - if (!text && mediaUrls.length === 0 && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text, + mediaUrls, + interactive, + hasChannelData, + }) + ) { continue; } normalizedPayloads.push({ diff --git a/src/interactive/payload.test.ts b/src/interactive/payload.test.ts new file mode 100644 index 00000000000..3000716cd2e --- /dev/null +++ b/src/interactive/payload.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + hasReplyChannelData, + hasReplyContent, + normalizeInteractiveReply, + resolveInteractiveTextFallback, +} from "./payload.js"; + +describe("hasReplyChannelData", () => { + it("accepts non-empty objects only", () => { + expect(hasReplyChannelData(undefined)).toBe(false); + expect(hasReplyChannelData({})).toBe(false); + expect(hasReplyChannelData([])).toBe(false); + expect(hasReplyChannelData({ slack: { blocks: [] } })).toBe(true); + }); +}); + +describe("hasReplyContent", () => { + it("treats whitespace-only text and empty structured payloads as empty", () => { + expect( + hasReplyContent({ + text: " ", + mediaUrls: ["", " "], + interactive: { blocks: [] }, + hasChannelData: false, + }), + ).toBe(false); + }); + + it("accepts shared interactive blocks and explicit extra content", () => { + expect( + hasReplyContent({ + interactive: { + blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }], + }, + }), + ).toBe(true); + expect( + hasReplyContent({ + text: " ", + extraContent: true, + }), + ).toBe(true); + }); +}); + +describe("interactive payload helpers", () => { + it("normalizes interactive replies and resolves text fallbacks", () => { + const interactive = normalizeInteractiveReply({ + blocks: [ + { type: "text", text: "First" }, + { type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }, + { type: "text", text: "Second" }, + ], + }); + + expect(interactive).toEqual({ + blocks: [ + { type: "text", text: "First" }, + { type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }, + { type: "text", text: "Second" }, + ], + }); + expect(resolveInteractiveTextFallback({ interactive })).toBe("First\n\nSecond"); + }); +}); diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts index 6fad12e1f1b..5ccd55d0eff 100644 --- a/src/interactive/payload.ts +++ b/src/interactive/payload.ts @@ -136,6 +136,30 @@ export function hasInteractiveReplyBlocks(value: unknown): value is InteractiveR return Boolean(normalizeInteractiveReply(value)); } +export function hasReplyChannelData(value: unknown): value is Record { + return Boolean( + value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0, + ); +} + +export function hasReplyContent(params: { + text?: string | null; + mediaUrl?: string | null; + mediaUrls?: ReadonlyArray; + interactive?: unknown; + hasChannelData?: boolean; + extraContent?: boolean; +}): boolean { + return Boolean( + params.text?.trim() || + params.mediaUrl?.trim() || + params.mediaUrls?.some((entry) => Boolean(entry?.trim())) || + hasInteractiveReplyBlocks(params.interactive) || + params.hasChannelData || + params.extraContent, + ); +} + export function resolveInteractiveTextFallback(params: { text?: string; interactive?: InteractiveReply; From ff558862f079c83e7435fa2fa4cb06538447ee97 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:35:21 +0000 Subject: [PATCH 019/133] refactor: extract discord shared interactive mapper --- .../discord/src/actions/handle-action.ts | 2 +- extensions/discord/src/components.ts | 66 +------------------ extensions/discord/src/outbound-adapter.ts | 2 +- .../discord/src/shared-interactive.test.ts | 2 +- extensions/discord/src/shared-interactive.ts | 66 +++++++++++++++++++ 5 files changed, 70 insertions(+), 68 deletions(-) create mode 100644 extensions/discord/src/shared-interactive.ts diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index a1b9caf3b93..4beb7d76de4 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -10,7 +10,7 @@ import { resolveReactionMessageId } from "../../../../src/channels/plugins/actio 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 "../components.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/components.ts b/extensions/discord/src/components.ts index 6725ad49a4d..27d29c0dbd7 100644 --- a/extensions/discord/src/components.ts +++ b/extensions/discord/src/components.ts @@ -25,8 +25,6 @@ import { type TopLevelComponents, } from "@buape/carbon"; import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10"; -import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js"; -import type { InteractiveButtonStyle, InteractiveReply } from "../../../src/interactive/payload.js"; export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp"; export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal"; @@ -213,69 +211,7 @@ export type DiscordComponentBuildResult = { entries: DiscordComponentEntry[]; modals: DiscordModalEntry[]; }; - -function resolveDiscordInteractiveButtonStyle( - style?: InteractiveButtonStyle, -): DiscordComponentButtonStyle | undefined { - return style ?? "secondary"; -} - -const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5; - -export function buildDiscordInteractiveComponents( - interactive?: InteractiveReply, -): DiscordComponentMessageSpec | undefined { - const blocks = reduceInteractiveReply( - interactive, - [] as NonNullable, - (state, block) => { - if (block.type === "text") { - const text = block.text.trim(); - if (text) { - state.push({ type: "text", text }); - } - return state; - } - if (block.type === "buttons") { - if (block.buttons.length === 0) { - return state; - } - for ( - let index = 0; - index < block.buttons.length; - index += DISCORD_INTERACTIVE_BUTTON_ROW_SIZE - ) { - state.push({ - type: "actions", - buttons: block.buttons - .slice(index, index + DISCORD_INTERACTIVE_BUTTON_ROW_SIZE) - .map((button) => ({ - label: button.label, - style: resolveDiscordInteractiveButtonStyle(button.style), - callbackData: button.value, - })), - }); - } - return state; - } - if (block.type === "select" && block.options.length > 0) { - state.push({ - type: "actions", - select: { - type: "string", - placeholder: block.placeholder, - options: block.options.map((option) => ({ - label: option.label, - value: option.value, - })), - }, - }); - } - return state; - }, - ); - return blocks.length > 0 ? { blocks } : undefined; -} +export { buildDiscordInteractiveComponents } from "./shared-interactive.js"; const BLOCK_ALIASES = new Map([ ["row", "actions"], diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 09796a7b0b3..1c6e0111869 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -8,7 +8,6 @@ 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"; import type { DiscordComponentMessageSpec } from "./components.js"; -import { buildDiscordInteractiveComponents } from "./components.js"; import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js"; import { normalizeDiscordOutboundTarget } from "./normalize.js"; import { @@ -17,6 +16,7 @@ import { sendPollDiscord, sendWebhookMessageDiscord, } from "./send.js"; +import { buildDiscordInteractiveComponents } from "./shared-interactive.js"; function resolveDiscordOutboundTarget(params: { to: string; diff --git a/extensions/discord/src/shared-interactive.test.ts b/extensions/discord/src/shared-interactive.test.ts index 827ad1126a8..33ce8f68ec1 100644 --- a/extensions/discord/src/shared-interactive.test.ts +++ b/extensions/discord/src/shared-interactive.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildDiscordInteractiveComponents } from "./components.js"; +import { buildDiscordInteractiveComponents } from "./shared-interactive.js"; describe("buildDiscordInteractiveComponents", () => { it("maps shared buttons and selects into Discord component blocks", () => { diff --git a/extensions/discord/src/shared-interactive.ts b/extensions/discord/src/shared-interactive.ts new file mode 100644 index 00000000000..d99f964f5c9 --- /dev/null +++ b/extensions/discord/src/shared-interactive.ts @@ -0,0 +1,66 @@ +import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js"; +import type { InteractiveButtonStyle, InteractiveReply } from "../../../src/interactive/payload.js"; +import type { DiscordComponentButtonStyle, DiscordComponentMessageSpec } from "./components.js"; + +function resolveDiscordInteractiveButtonStyle( + style?: InteractiveButtonStyle, +): DiscordComponentButtonStyle | undefined { + return style ?? "secondary"; +} + +const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5; + +export function buildDiscordInteractiveComponents( + interactive?: InteractiveReply, +): DiscordComponentMessageSpec | undefined { + const blocks = reduceInteractiveReply( + interactive, + [] as NonNullable, + (state, block) => { + if (block.type === "text") { + const text = block.text.trim(); + if (text) { + state.push({ type: "text", text }); + } + return state; + } + if (block.type === "buttons") { + if (block.buttons.length === 0) { + return state; + } + for ( + let index = 0; + index < block.buttons.length; + index += DISCORD_INTERACTIVE_BUTTON_ROW_SIZE + ) { + state.push({ + type: "actions", + buttons: block.buttons + .slice(index, index + DISCORD_INTERACTIVE_BUTTON_ROW_SIZE) + .map((button) => ({ + label: button.label, + style: resolveDiscordInteractiveButtonStyle(button.style), + callbackData: button.value, + })), + }); + } + return state; + } + if (block.type === "select" && block.options.length > 0) { + state.push({ + type: "actions", + select: { + type: "string", + placeholder: block.placeholder, + options: block.options.map((option) => ({ + label: option.label, + value: option.value, + })), + }, + }); + } + return state; + }, + ); + return blocks.length > 0 ? { blocks } : undefined; +} From ecaafb6a4f2981702b8e4785476affee74779dd7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:37:12 +0000 Subject: [PATCH 020/133] refactor: unify telegram interactive button resolution --- extensions/telegram/src/button-types.test.ts | 69 ++++++++++++++++++++ extensions/telegram/src/button-types.ts | 15 ++++- extensions/telegram/src/channel-actions.ts | 10 +-- extensions/telegram/src/outbound-adapter.ts | 8 ++- 4 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 extensions/telegram/src/button-types.test.ts diff --git a/extensions/telegram/src/button-types.test.ts b/extensions/telegram/src/button-types.test.ts new file mode 100644 index 00000000000..849caac62ac --- /dev/null +++ b/extensions/telegram/src/button-types.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { buildTelegramInteractiveButtons, resolveTelegramInlineButtons } from "./button-types.js"; + +describe("buildTelegramInteractiveButtons", () => { + it("maps shared buttons and selects into Telegram inline rows", () => { + expect( + buildTelegramInteractiveButtons({ + blocks: [ + { + type: "buttons", + buttons: [ + { label: "Approve", value: "approve", style: "success" }, + { label: "Reject", value: "reject", style: "danger" }, + { label: "Later", value: "later" }, + { label: "Archive", value: "archive" }, + ], + }, + { + type: "select", + options: [{ label: "Alpha", value: "alpha" }], + }, + ], + }), + ).toEqual([ + [ + { text: "Approve", callback_data: "approve", style: "success" }, + { text: "Reject", callback_data: "reject", style: "danger" }, + { text: "Later", callback_data: "later", style: undefined }, + ], + [{ text: "Archive", callback_data: "archive", style: undefined }], + [{ text: "Alpha", callback_data: "alpha", style: undefined }], + ]); + }); +}); + +describe("resolveTelegramInlineButtons", () => { + it("prefers explicit buttons over shared interactive blocks", () => { + const explicit = [[{ text: "Keep", callback_data: "keep" }]] as const; + + expect( + resolveTelegramInlineButtons({ + buttons: explicit, + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Override", value: "override" }], + }, + ], + }, + }), + ).toBe(explicit); + }); + + it("derives buttons from raw interactive payloads", () => { + expect( + resolveTelegramInlineButtons({ + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Retry", value: "retry", style: "primary" }], + }, + ], + }, + }), + ).toEqual([[{ text: "Retry", callback_data: "retry", style: "primary" }]]); + }); +}); diff --git a/extensions/telegram/src/button-types.ts b/extensions/telegram/src/button-types.ts index f9c77ac190b..a6eae71995b 100644 --- a/extensions/telegram/src/button-types.ts +++ b/extensions/telegram/src/button-types.ts @@ -1,5 +1,9 @@ import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js"; -import type { InteractiveReply, InteractiveReplyButton } from "../../../src/interactive/payload.js"; +import { + normalizeInteractiveReply, + type InteractiveReply, + type InteractiveReplyButton, +} from "../../../src/interactive/payload.js"; export type TelegramButtonStyle = "danger" | "success" | "primary"; @@ -60,3 +64,12 @@ export function buildTelegramInteractiveButtons( ); return rows.length > 0 ? rows : undefined; } + +export function resolveTelegramInlineButtons(params: { + buttons?: TelegramInlineButtons; + interactive?: unknown; +}): TelegramInlineButtons | undefined { + return ( + params.buttons ?? buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive)) + ); +} diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 246ed45c0e3..84548374f05 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -15,7 +15,6 @@ import type { ChannelMessageActionName, } from "../../../src/channels/plugins/types.js"; import type { TelegramActionConfig } from "../../../src/config/types.telegram.js"; -import { normalizeInteractiveReply } from "../../../src/interactive/payload.js"; import { readBooleanParam } from "../../../src/plugin-sdk/boolean-param.js"; import { extractToolSend } from "../../../src/plugin-sdk/tool-send.js"; import { resolveTelegramPollVisibility } from "../../../src/poll-params.js"; @@ -24,7 +23,7 @@ import { listEnabledTelegramAccounts, resolveTelegramPollActionGateState, } from "./accounts.js"; -import { buildTelegramInteractiveButtons } from "./button-types.js"; +import { resolveTelegramInlineButtons } from "./button-types.js"; import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js"; const providerId = "telegram"; @@ -32,9 +31,10 @@ const providerId = "telegram"; function readTelegramSendParams(params: Record) { const to = readStringParam(params, "to", { required: true }); const mediaUrl = readStringParam(params, "media", { trim: false }); - const buttons = - params.buttons ?? - buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive)); + const buttons = resolveTelegramInlineButtons({ + buttons: params.buttons as ReturnType, + interactive: params.interactive, + }); const hasButtons = Array.isArray(buttons) && buttons.length > 0; const message = readStringParam(params, "message", { required: !mediaUrl && !hasButtons, diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index b5ed5ccfcb4..e8c0530d06b 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -10,7 +10,7 @@ import { } from "../../../src/infra/outbound/send-deps.js"; import { resolveInteractiveTextFallback } from "../../../src/interactive/payload.js"; import type { TelegramInlineButtons } from "./button-types.js"; -import { buildTelegramInteractiveButtons } from "./button-types.js"; +import { resolveTelegramInlineButtons } from "./button-types.js"; import { markdownToTelegramHtmlChunks } from "./format.js"; import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; import { sendMessageTelegram } from "./send.js"; @@ -67,8 +67,10 @@ export async function sendTelegramPayloadMessages(params: { interactive: params.payload.interactive, }) ?? ""; const mediaUrls = resolvePayloadMediaUrls(params.payload); - const interactiveButtons = buildTelegramInteractiveButtons(params.payload.interactive); - const buttons = telegramData?.buttons ?? interactiveButtons; + const buttons = resolveTelegramInlineButtons({ + buttons: telegramData?.buttons, + interactive: params.payload.interactive, + }); const payloadOpts = { ...params.baseOpts, quoteText, From 2852eab323bd5e9a9c41bedf8e79a2e4fac35a24 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:40:07 +0000 Subject: [PATCH 021/133] build: add land gate parity script --- docs/ci.md | 3 +++ package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/ci.md b/docs/ci.md index e8710b87cb1..25445d6c0ed 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -53,4 +53,7 @@ pnpm check # types + lint + format pnpm test # vitest tests pnpm check:docs # docs format + lint + broken links pnpm release:check # validate npm pack +pnpm land:gate # maintainer land gate: frozen-lock install + check + build + test + release:check ``` + +`pnpm land:gate` intentionally includes the same frozen-lockfile install step CI uses before running `check`, `build`, `test`, and `release:check`. Use it when you want local merge-gate parity instead of piecemeal commands. diff --git a/package.json b/package.json index caa950adf1f..6aa553f5302 100644 --- a/package.json +++ b/package.json @@ -270,6 +270,7 @@ "ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'", "ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'", "ios:run": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'", + "land:gate": "pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true && pnpm check && pnpm build && pnpm test && pnpm release:check", "lint": "oxlint --type-aware", "lint:agent:ingress-owner": "node scripts/check-ingress-agent-owner-context.mjs", "lint:all": "pnpm lint && pnpm lint:swift", From 465567b1eb1cbb03a37734ea1a3ac78a32f60a72 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:44:31 +0000 Subject: [PATCH 022/133] test: fix setup wizard smoke mocks --- src/cli/program.test-mocks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index 8f82e71fca5..15595755dc3 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -34,8 +34,8 @@ export const statusCommand = programMocks.statusCommand as AnyMock; export const configureCommand = programMocks.configureCommand as AnyMock; export const configureCommandWithSections = programMocks.configureCommandWithSections as AnyMock; export const setupCommand = programMocks.setupCommand as AnyMock; -export const setupWizardCommand = programMocks.setupWizardCommand as AnyMock; export const onboardCommand = programMocks.onboardCommand as AnyMock; +export const setupWizardCommand = programMocks.setupWizardCommand as AnyMock; export const callGateway = programMocks.callGateway as AnyMock; export const runChannelLogin = programMocks.runChannelLogin as AnyMock; export const runChannelLogout = programMocks.runChannelLogout as AnyMock; From 0a6f22a69478573f0e8d0ccb622dfac15f33fd43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:49:29 +0000 Subject: [PATCH 023/133] docs: sync config baseline --- docs/.generated/config-baseline.json | 10 +++++----- docs/.generated/config-baseline.jsonl | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index dee3827bbcc..8a30b9c6fde 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -58804,7 +58804,7 @@ "advanced" ], "label": "Setup Wizard State", - "help": "Setup wizard state tracking fields that record the most recent guided onboarding run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.", + "help": "Setup wizard state tracking fields that record the most recent guided setup run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.", "hasChildren": true }, { @@ -58818,7 +58818,7 @@ "advanced" ], "label": "Wizard Last Run Timestamp", - "help": "ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm onboarding recency during support and operational audits.", + "help": "ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm setup recency during support and operational audits.", "hasChildren": false }, { @@ -58832,7 +58832,7 @@ "advanced" ], "label": "Wizard Last Run Command", - "help": "Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce onboarding steps when verifying setup regressions.", + "help": "Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce setup steps when verifying setup regressions.", "hasChildren": false }, { @@ -58846,7 +58846,7 @@ "advanced" ], "label": "Wizard Last Run Commit", - "help": "Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate onboarding behavior with exact source state during debugging.", + "help": "Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate setup behavior with exact source state during debugging.", "hasChildren": false }, { @@ -58874,7 +58874,7 @@ "advanced" ], "label": "Wizard Last Run Version", - "help": "OpenClaw version recorded at the time of the most recent wizard run on this config. Use this when diagnosing behavior differences across version-to-version onboarding changes.", + "help": "OpenClaw version recorded at the time of the most recent wizard run on this config. Use this when diagnosing behavior differences across version-to-version setup changes.", "hasChildren": false } ] diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 004f48478bb..f8a5068394e 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -5086,9 +5086,9 @@ {"recordType":"path","path":"web.reconnect.jitter","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Web Reconnect Jitter","help":"Randomization factor (0-1) applied to reconnect delays to desynchronize clients after outage events. Keep non-zero jitter in multi-client deployments to reduce synchronized spikes.","hasChildren":false} {"recordType":"path","path":"web.reconnect.maxAttempts","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Web Reconnect Max Attempts","help":"Maximum reconnect attempts before giving up for the current failure sequence (0 means no retries). Use finite caps for controlled failure handling in automation-sensitive environments.","hasChildren":false} {"recordType":"path","path":"web.reconnect.maxMs","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Web Reconnect Max Delay (ms)","help":"Maximum reconnect backoff cap in milliseconds to bound retry delay growth over repeated failures. Use a reasonable cap so recovery remains timely after prolonged outages.","hasChildren":false} -{"recordType":"path","path":"wizard","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Setup Wizard State","help":"Setup wizard state tracking fields that record the most recent guided onboarding run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.","hasChildren":true} -{"recordType":"path","path":"wizard.lastRunAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Timestamp","help":"ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm onboarding recency during support and operational audits.","hasChildren":false} -{"recordType":"path","path":"wizard.lastRunCommand","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Command","help":"Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce onboarding steps when verifying setup regressions.","hasChildren":false} -{"recordType":"path","path":"wizard.lastRunCommit","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Commit","help":"Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate onboarding behavior with exact source state during debugging.","hasChildren":false} +{"recordType":"path","path":"wizard","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Setup Wizard State","help":"Setup wizard state tracking fields that record the most recent guided setup run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.","hasChildren":true} +{"recordType":"path","path":"wizard.lastRunAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Timestamp","help":"ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm setup recency during support and operational audits.","hasChildren":false} +{"recordType":"path","path":"wizard.lastRunCommand","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Command","help":"Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce setup steps when verifying setup regressions.","hasChildren":false} +{"recordType":"path","path":"wizard.lastRunCommit","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Commit","help":"Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate setup behavior with exact source state during debugging.","hasChildren":false} {"recordType":"path","path":"wizard.lastRunMode","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Mode","help":"Wizard execution mode recorded as \"local\" or \"remote\" for the most recent setup flow. Use this to understand whether setup targeted direct local runtime or remote gateway topology.","hasChildren":false} -{"recordType":"path","path":"wizard.lastRunVersion","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Version","help":"OpenClaw version recorded at the time of the most recent wizard run on this config. Use this when diagnosing behavior differences across version-to-version onboarding changes.","hasChildren":false} +{"recordType":"path","path":"wizard.lastRunVersion","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Version","help":"OpenClaw version recorded at the time of the most recent wizard run on this config. Use this when diagnosing behavior differences across version-to-version setup changes.","hasChildren":false} From ebfd32efc31cb26a204358811006dd9d6cc318a9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:26:15 -0700 Subject: [PATCH 024/133] Status: split heartbeat summary helpers --- src/commands/health.ts | 2 +- src/commands/status.summary.test.ts | 2 +- src/commands/status.summary.ts | 2 +- src/infra/heartbeat-runner.ts | 114 +++------------------------ src/infra/heartbeat-summary.ts | 118 ++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 105 deletions(-) create mode 100644 src/infra/heartbeat-summary.ts diff --git a/src/commands/health.ts b/src/commands/health.ts index ddfc308bda4..301cb55282e 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -14,7 +14,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import { type HeartbeatSummary, resolveHeartbeatSummaryForAgent, -} from "../infra/heartbeat-runner.js"; +} from "../infra/heartbeat-summary.js"; import { buildChannelAccountBindings, resolvePreferredAccountId } from "../routing/bindings.js"; import { normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index c0344065126..2045c380e1b 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -48,7 +48,7 @@ vi.mock("../infra/channel-summary.js", () => ({ buildChannelSummary: vi.fn(async () => ["ok"]), })); -vi.mock("../infra/heartbeat-runner.js", () => ({ +vi.mock("../infra/heartbeat-summary.js", () => ({ resolveHeartbeatSummaryForAgent: vi.fn(() => ({ enabled: true, every: "5m", diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index b028c99ab6d..6de3b282648 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -16,7 +16,7 @@ import { listAgentsForGateway, resolveSessionModelRef, } from "../gateway/session-utils.js"; -import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-runner.js"; +import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { resolveRuntimeServiceVersion } from "../version.js"; diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 1f6ae8767e9..34b3a7b5f86 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -11,7 +11,6 @@ import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js"; import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, - DEFAULT_HEARTBEAT_EVERY, isHeartbeatContentEffectivelyEmpty, resolveHeartbeatPrompt as resolveHeartbeatPromptText, stripHeartbeatToken, @@ -21,7 +20,6 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { getChannelPlugin } from "../channels/plugins/index.js"; import type { ChannelHeartbeatDeps } from "../channels/plugins/types.js"; -import { parseDurationMs } from "../cli/parse-duration.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { @@ -56,6 +54,12 @@ import { } from "./heartbeat-events-filter.js"; import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js"; import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js"; +import { + isHeartbeatEnabledForAgent, + resolveHeartbeatIntervalMs, + resolveHeartbeatSummaryForAgent, + type HeartbeatSummary, +} from "./heartbeat-summary.js"; import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js"; import { areHeartbeatsEnabled, @@ -84,6 +88,12 @@ export type HeartbeatDeps = OutboundSendDeps & const log = createSubsystemLogger("gateway/heartbeat"); export { areHeartbeatsEnabled, setHeartbeatsEnabled }; +export { + isHeartbeatEnabledForAgent, + resolveHeartbeatIntervalMs, + resolveHeartbeatSummaryForAgent, + type HeartbeatSummary, +} from "./heartbeat-summary.js"; type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; type HeartbeatAgent = { @@ -91,17 +101,6 @@ type HeartbeatAgent = { heartbeat?: HeartbeatConfig; }; -export type HeartbeatSummary = { - enabled: boolean; - every: string; - everyMs: number | null; - prompt: string; - target: string; - model?: string; - ackMaxChars: number; -}; - -const DEFAULT_HEARTBEAT_TARGET = "none"; export { isCronSystemEvent }; type HeartbeatAgentState = { @@ -122,18 +121,6 @@ function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) { return list.some((entry) => Boolean(entry?.heartbeat)); } -export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string): boolean { - const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); - const list = cfg.agents?.list ?? []; - const hasExplicit = hasExplicitHeartbeatAgents(cfg); - if (hasExplicit) { - return list.some( - (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId, - ); - } - return resolvedAgentId === resolveDefaultAgentId(cfg); -} - function resolveHeartbeatConfig( cfg: OpenClawConfig, agentId?: string, @@ -149,54 +136,6 @@ function resolveHeartbeatConfig( return { ...defaults, ...overrides }; } -export function resolveHeartbeatSummaryForAgent( - cfg: OpenClawConfig, - agentId?: string, -): HeartbeatSummary { - const defaults = cfg.agents?.defaults?.heartbeat; - const overrides = agentId ? resolveAgentConfig(cfg, agentId)?.heartbeat : undefined; - const enabled = isHeartbeatEnabledForAgent(cfg, agentId); - - if (!enabled) { - return { - enabled: false, - every: "disabled", - everyMs: null, - prompt: resolveHeartbeatPromptText(defaults?.prompt), - target: defaults?.target ?? DEFAULT_HEARTBEAT_TARGET, - model: defaults?.model, - ackMaxChars: Math.max(0, defaults?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS), - }; - } - - const merged = defaults || overrides ? { ...defaults, ...overrides } : undefined; - const every = merged?.every ?? defaults?.every ?? overrides?.every ?? DEFAULT_HEARTBEAT_EVERY; - const everyMs = resolveHeartbeatIntervalMs(cfg, undefined, merged); - const prompt = resolveHeartbeatPromptText( - merged?.prompt ?? defaults?.prompt ?? overrides?.prompt, - ); - const target = - merged?.target ?? defaults?.target ?? overrides?.target ?? DEFAULT_HEARTBEAT_TARGET; - const model = merged?.model ?? defaults?.model ?? overrides?.model; - const ackMaxChars = Math.max( - 0, - merged?.ackMaxChars ?? - defaults?.ackMaxChars ?? - overrides?.ackMaxChars ?? - DEFAULT_HEARTBEAT_ACK_MAX_CHARS, - ); - - return { - enabled: true, - every, - everyMs, - prompt, - target, - model, - ackMaxChars, - }; -} - function resolveHeartbeatAgents(cfg: OpenClawConfig): HeartbeatAgent[] { const list = cfg.agents?.list ?? []; if (hasExplicitHeartbeatAgents(cfg)) { @@ -212,35 +151,6 @@ function resolveHeartbeatAgents(cfg: OpenClawConfig): HeartbeatAgent[] { return [{ agentId: fallbackId, heartbeat: resolveHeartbeatConfig(cfg, fallbackId) }]; } -export function resolveHeartbeatIntervalMs( - cfg: OpenClawConfig, - overrideEvery?: string, - heartbeat?: HeartbeatConfig, -) { - const raw = - overrideEvery ?? - heartbeat?.every ?? - cfg.agents?.defaults?.heartbeat?.every ?? - DEFAULT_HEARTBEAT_EVERY; - if (!raw) { - return null; - } - const trimmed = String(raw).trim(); - if (!trimmed) { - return null; - } - let ms: number; - try { - ms = parseDurationMs(trimmed, { defaultUnit: "m" }); - } catch { - return null; - } - if (ms <= 0) { - return null; - } - return ms; -} - export function resolveHeartbeatPrompt(cfg: OpenClawConfig, heartbeat?: HeartbeatConfig) { return resolveHeartbeatPromptText(heartbeat?.prompt ?? cfg.agents?.defaults?.heartbeat?.prompt); } diff --git a/src/infra/heartbeat-summary.ts b/src/infra/heartbeat-summary.ts new file mode 100644 index 00000000000..89650de44a6 --- /dev/null +++ b/src/infra/heartbeat-summary.ts @@ -0,0 +1,118 @@ +import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + DEFAULT_HEARTBEAT_EVERY, + resolveHeartbeatPrompt as resolveHeartbeatPromptText, +} from "../auto-reply/heartbeat.js"; +import { parseDurationMs } from "../cli/parse-duration.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; +import { normalizeAgentId } from "../routing/session-key.js"; + +type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; + +export type HeartbeatSummary = { + enabled: boolean; + every: string; + everyMs: number | null; + prompt: string; + target: string; + model?: string; + ackMaxChars: number; +}; + +const DEFAULT_HEARTBEAT_TARGET = "none"; + +function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) { + const list = cfg.agents?.list ?? []; + return list.some((entry) => Boolean(entry?.heartbeat)); +} + +export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string): boolean { + const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); + const list = cfg.agents?.list ?? []; + const hasExplicit = hasExplicitHeartbeatAgents(cfg); + if (hasExplicit) { + return list.some( + (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId, + ); + } + return resolvedAgentId === resolveDefaultAgentId(cfg); +} + +export function resolveHeartbeatIntervalMs( + cfg: OpenClawConfig, + overrideEvery?: string, + heartbeat?: HeartbeatConfig, +) { + const raw = + overrideEvery ?? + heartbeat?.every ?? + cfg.agents?.defaults?.heartbeat?.every ?? + DEFAULT_HEARTBEAT_EVERY; + if (!raw) { + return null; + } + const trimmed = String(raw).trim(); + if (!trimmed) { + return null; + } + let ms: number; + try { + ms = parseDurationMs(trimmed, { defaultUnit: "m" }); + } catch { + return null; + } + if (ms <= 0) { + return null; + } + return ms; +} + +export function resolveHeartbeatSummaryForAgent( + cfg: OpenClawConfig, + agentId?: string, +): HeartbeatSummary { + const defaults = cfg.agents?.defaults?.heartbeat; + const overrides = agentId ? resolveAgentConfig(cfg, agentId)?.heartbeat : undefined; + const enabled = isHeartbeatEnabledForAgent(cfg, agentId); + + if (!enabled) { + return { + enabled: false, + every: "disabled", + everyMs: null, + prompt: resolveHeartbeatPromptText(defaults?.prompt), + target: defaults?.target ?? DEFAULT_HEARTBEAT_TARGET, + model: defaults?.model, + ackMaxChars: Math.max(0, defaults?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS), + }; + } + + const merged = defaults || overrides ? { ...defaults, ...overrides } : undefined; + const every = merged?.every ?? defaults?.every ?? overrides?.every ?? DEFAULT_HEARTBEAT_EVERY; + const everyMs = resolveHeartbeatIntervalMs(cfg, undefined, merged); + const prompt = resolveHeartbeatPromptText( + merged?.prompt ?? defaults?.prompt ?? overrides?.prompt, + ); + const target = + merged?.target ?? defaults?.target ?? overrides?.target ?? DEFAULT_HEARTBEAT_TARGET; + const model = merged?.model ?? defaults?.model ?? overrides?.model; + const ackMaxChars = Math.max( + 0, + merged?.ackMaxChars ?? + defaults?.ackMaxChars ?? + overrides?.ackMaxChars ?? + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + ); + + return { + enabled: true, + every, + everyMs, + prompt, + target, + model, + ackMaxChars, + }; +} From 4cb46f223c4bf7e5e91721d74418fe76ea128c30 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:39:48 -0700 Subject: [PATCH 025/133] Security: trim audit policy import surfaces --- src/agents/pi-tools.policy.ts | 48 +++---------------------------- src/agents/tool-policy-match.ts | 44 ++++++++++++++++++++++++++++ src/gateway/hooks-policy.ts | 24 ++++++++++++++++ src/gateway/hooks.ts | 26 ++--------------- src/security/audit-extra.async.ts | 8 ++---- src/security/audit-extra.sync.ts | 10 +++---- 6 files changed, 81 insertions(+), 79 deletions(-) create mode 100644 src/agents/tool-policy-match.ts create mode 100644 src/gateway/hooks-policy.ts diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 0353c454865..a6f8651f72d 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -7,7 +7,6 @@ import { normalizeAgentId } from "../routing/session-key.js"; import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js"; -import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js"; import type { SandboxToolPolicy } from "./sandbox.js"; @@ -15,34 +14,8 @@ import { resolveStoredSubagentCapabilities, type SubagentSessionRole, } from "./subagent-capabilities.js"; -import { expandToolGroups, normalizeToolName } from "./tool-policy.js"; - -function makeToolPolicyMatcher(policy: SandboxToolPolicy) { - const deny = compileGlobPatterns({ - raw: expandToolGroups(policy.deny ?? []), - normalize: normalizeToolName, - }); - const allow = compileGlobPatterns({ - raw: expandToolGroups(policy.allow ?? []), - normalize: normalizeToolName, - }); - return (name: string) => { - const normalized = normalizeToolName(name); - if (matchesAnyGlobPattern(normalized, deny)) { - return false; - } - if (allow.length === 0) { - return true; - } - if (matchesAnyGlobPattern(normalized, allow)) { - return true; - } - if (normalized === "apply_patch" && matchesAnyGlobPattern("exec", allow)) { - return true; - } - return false; - }; -} +import { isToolAllowedByPolicies, isToolAllowedByPolicyName } from "./tool-policy-match.js"; +import { normalizeToolName } from "./tool-policy.js"; /** * Tools always denied for sub-agents regardless of depth. @@ -140,19 +113,11 @@ export function resolveSubagentToolPolicyForSession( return { allow: mergedAllow, deny }; } -export function isToolAllowedByPolicyName(name: string, policy?: SandboxToolPolicy): boolean { - if (!policy) { - return true; - } - return makeToolPolicyMatcher(policy)(name); -} - export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolPolicy) { if (!policy) { return tools; } - const matcher = makeToolPolicyMatcher(policy); - return tools.filter((tool) => matcher(tool.name)); + return tools.filter((tool) => isToolAllowedByPolicyName(tool.name, policy)); } type ToolPolicyConfig = { @@ -381,9 +346,4 @@ export function resolveGroupToolPolicy(params: { return pickSandboxToolPolicy(toolsConfig); } -export function isToolAllowedByPolicies( - name: string, - policies: Array, -) { - return policies.every((policy) => isToolAllowedByPolicyName(name, policy)); -} +export { isToolAllowedByPolicies, isToolAllowedByPolicyName } from "./tool-policy-match.js"; diff --git a/src/agents/tool-policy-match.ts b/src/agents/tool-policy-match.ts new file mode 100644 index 00000000000..112bd94be10 --- /dev/null +++ b/src/agents/tool-policy-match.ts @@ -0,0 +1,44 @@ +import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js"; +import type { SandboxToolPolicy } from "./sandbox/types.js"; +import { expandToolGroups, normalizeToolName } from "./tool-policy.js"; + +function makeToolPolicyMatcher(policy: SandboxToolPolicy) { + const deny = compileGlobPatterns({ + raw: expandToolGroups(policy.deny ?? []), + normalize: normalizeToolName, + }); + const allow = compileGlobPatterns({ + raw: expandToolGroups(policy.allow ?? []), + normalize: normalizeToolName, + }); + return (name: string) => { + const normalized = normalizeToolName(name); + if (matchesAnyGlobPattern(normalized, deny)) { + return false; + } + if (allow.length === 0) { + return true; + } + if (matchesAnyGlobPattern(normalized, allow)) { + return true; + } + if (normalized === "apply_patch" && matchesAnyGlobPattern("exec", allow)) { + return true; + } + return false; + }; +} + +export function isToolAllowedByPolicyName(name: string, policy?: SandboxToolPolicy): boolean { + if (!policy) { + return true; + } + return makeToolPolicyMatcher(policy)(name); +} + +export function isToolAllowedByPolicies( + name: string, + policies: Array, +) { + return policies.every((policy) => isToolAllowedByPolicyName(name, policy)); +} diff --git a/src/gateway/hooks-policy.ts b/src/gateway/hooks-policy.ts new file mode 100644 index 00000000000..27ce19b40cf --- /dev/null +++ b/src/gateway/hooks-policy.ts @@ -0,0 +1,24 @@ +import { normalizeAgentId } from "../routing/session-key.js"; + +export function resolveAllowedAgentIds(raw: string[] | undefined): Set | undefined { + if (!Array.isArray(raw)) { + return undefined; + } + const allowed = new Set(); + let hasWildcard = false; + for (const entry of raw) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + if (trimmed === "*") { + hasWildcard = true; + break; + } + allowed.add(normalizeAgentId(trimmed)); + } + if (hasWildcard) { + return undefined; + } + return allowed; +} diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index f371e3565a9..d9e23060f04 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -5,9 +5,10 @@ import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js"; -import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js"; +import { resolveAllowedAgentIds } from "./hooks-policy.js"; const DEFAULT_HOOKS_PATH = "/hooks"; const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024; @@ -100,29 +101,6 @@ function resolveKnownAgentIds(cfg: OpenClawConfig, defaultAgentId: string): Set< return known; } -export function resolveAllowedAgentIds(raw: string[] | undefined): Set | undefined { - if (!Array.isArray(raw)) { - return undefined; - } - const allowed = new Set(); - let hasWildcard = false; - for (const entry of raw) { - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - if (trimmed === "*") { - hasWildcard = true; - break; - } - allowed.add(normalizeAgentId(trimmed)); - } - if (hasWildcard) { - return undefined; - } - return allowed; -} - function resolveSessionKey(raw: string | undefined): string | undefined { const value = raw?.trim(); return value ? value : undefined; diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 7ad36855852..88df46bafa1 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -6,15 +6,13 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; -import { - resolveSandboxConfigForAgent, - resolveSandboxToolPolicyForAgent, -} from "../agents/sandbox.js"; +import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import { SANDBOX_BROWSER_SECURITY_HASH_EPOCH } from "../agents/sandbox/constants.js"; import { execDockerRaw, type ExecDockerRawResult } from "../agents/sandbox/docker.js"; +import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import { loadWorkspaceSkillEntries } from "../agents/skills.js"; +import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js"; import { formatCliCommand } from "../cli/command-format.js"; diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 79a701c5489..bebcc44c0d0 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -1,16 +1,14 @@ -import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; -import { - resolveSandboxConfigForAgent, - resolveSandboxToolPolicyForAgent, -} from "../agents/sandbox.js"; +import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import { isDangerousNetworkMode, normalizeNetworkMode } from "../agents/sandbox/network-mode.js"; /** * Synchronous security audit collector functions. * * These functions analyze config-based security properties without I/O. */ +import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import { getBlockedBindReason } from "../agents/sandbox/validate-sandbox-security.js"; +import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { resolveBrowserConfig } from "../browser/config.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -21,7 +19,7 @@ import { } from "../config/model-input.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; -import { resolveAllowedAgentIds } from "../gateway/hooks.js"; +import { resolveAllowedAgentIds } from "../gateway/hooks-policy.js"; import { DEFAULT_DANGEROUS_NODE_COMMANDS, resolveNodeCommandAllowlist, From a2119efe1c2e08d5197d23925bdb43d04f04db5e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:43:21 -0700 Subject: [PATCH 026/133] Security: lazy-load deep skill audit helpers --- src/security/audit-extra.async.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 88df46bafa1..54f411eb73b 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -11,7 +11,6 @@ import { SANDBOX_BROWSER_SECURITY_HASH_EPOCH } from "../agents/sandbox/constants import { execDockerRaw, type ExecDockerRawResult } from "../agents/sandbox/docker.js"; import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; -import { loadWorkspaceSkillEntries } from "../agents/skills.js"; import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js"; @@ -54,6 +53,12 @@ type ExecDockerRawFn = ( type CodeSafetySummaryCache = Map>; const MAX_WORKSPACE_SKILL_SCAN_FILES_PER_WORKSPACE = 2_000; const MAX_WORKSPACE_SKILL_ESCAPE_DETAIL_ROWS = 12; +let skillsModulePromise: Promise | undefined; + +function loadSkillsModule() { + skillsModulePromise ??= import("../agents/skills.js"); + return skillsModulePromise; +} // -------------------------------------------------------------------------- // Helpers @@ -1245,6 +1250,7 @@ export async function collectInstalledSkillsCodeSafetyFindings(params: { const pluginExtensionsDir = path.join(params.stateDir, "extensions"); const scannedSkillDirs = new Set(); const workspaceDirs = listAgentWorkspaceDirs(params.cfg); + const { loadWorkspaceSkillEntries } = await loadSkillsModule(); for (const workspaceDir of workspaceDirs) { const entries = loadWorkspaceSkillEntries(workspaceDir, { config: params.cfg }); From 5f42389d8d00d814e4c52ea1c5c37d34bcdeb99f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:45:36 -0700 Subject: [PATCH 027/133] Security: lazy-load audit config snapshot IO --- src/security/audit-extra.async.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 54f411eb73b..5e9c4036e09 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -18,7 +18,6 @@ import { formatCliCommand } from "../cli/command-format.js"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { resolveNativeSkillsEnabled } from "../config/commands.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js"; -import { createConfigIO } from "../config/config.js"; import { collectIncludePathsRecursive } from "../config/includes-scan.js"; import { resolveOAuthDir } from "../config/paths.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; @@ -54,12 +53,18 @@ type CodeSafetySummaryCache = Map>; const MAX_WORKSPACE_SKILL_SCAN_FILES_PER_WORKSPACE = 2_000; const MAX_WORKSPACE_SKILL_ESCAPE_DETAIL_ROWS = 12; let skillsModulePromise: Promise | undefined; +let configModulePromise: Promise | undefined; function loadSkillsModule() { skillsModulePromise ??= import("../agents/skills.js"); return skillsModulePromise; } +function loadConfigModule() { + configModulePromise ??= import("../config/config.js"); + return configModulePromise; +} + // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- @@ -1133,6 +1138,7 @@ export async function readConfigSnapshotForAudit(params: { env: NodeJS.ProcessEnv; configPath: string; }): Promise { + const { createConfigIO } = await loadConfigModule(); return await createConfigIO({ env: params.env, configPath: params.configPath, From d47fc009dea25e99275333b8594d0626952ba3c1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:47:42 -0700 Subject: [PATCH 028/133] Config: keep native command defaults off heavy channel registry --- src/config/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/commands.ts b/src/config/commands.ts index 4d174d7c396..29992b3a1cd 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -1,5 +1,5 @@ -import { normalizeChannelId } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; +import { normalizeChannelId } from "../channels/registry.js"; import { isPlainObject } from "../infra/plain-object.js"; import type { CommandsConfig, NativeCommandsSetting } from "./types.js"; From c4b18ab3c9ae8c586bbfe02a279b13b2817cc3b8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:58:50 -0700 Subject: [PATCH 029/133] Status: split lightweight gateway agent list --- src/commands/status-all/agents.ts | 4 +- src/commands/status.agent-local.ts | 4 +- src/commands/status.summary.test.ts | 11 +++- src/commands/status.summary.ts | 9 +-- src/commands/status.test.ts | 17 ++++-- src/gateway/agent-list.ts | 88 +++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 src/gateway/agent-list.ts diff --git a/src/commands/status-all/agents.ts b/src/commands/status-all/agents.ts index caf1ae03ed2..e8d7c485fe5 100644 --- a/src/commands/status-all/agents.ts +++ b/src/commands/status-all/agents.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; -import { listAgentsForGateway } from "../../gateway/session-utils.js"; +import { listGatewayAgentsBasic } from "../../gateway/agent-list.js"; async function fileExists(p: string): Promise { try { @@ -15,7 +15,7 @@ async function fileExists(p: string): Promise { } export async function getAgentLocalStatuses(cfg: OpenClawConfig) { - const agentList = listAgentsForGateway(cfg); + const agentList = listGatewayAgentsBasic(cfg); const now = Date.now(); const agents = await Promise.all( diff --git a/src/commands/status.agent-local.ts b/src/commands/status.agent-local.ts index 5c57036eb97..ce17f9ab94f 100644 --- a/src/commands/status.agent-local.ts +++ b/src/commands/status.agent-local.ts @@ -4,7 +4,7 @@ import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; -import { listAgentsForGateway } from "../gateway/session-utils.js"; +import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; export type AgentLocalStatus = { id: string; @@ -36,7 +36,7 @@ async function fileExists(p: string): Promise { export async function getAgentLocalStatuses( cfg: OpenClawConfig = loadConfig(), ): Promise { - const agentList = listAgentsForGateway(cfg); + const agentList = listGatewayAgentsBasic(cfg); const now = Date.now(); const statuses: AgentLocalStatus[] = []; diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 2045c380e1b..12ce55844c3 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -32,12 +32,15 @@ vi.mock("../config/sessions.js", () => ({ resolveStorePath: vi.fn(() => "/tmp/sessions.json"), })); -vi.mock("../gateway/session-utils.js", () => ({ - classifySessionKey: vi.fn(() => "direct"), - listAgentsForGateway: vi.fn(() => ({ +vi.mock("../gateway/agent-list.js", () => ({ + listGatewayAgentsBasic: vi.fn(() => ({ defaultId: "main", agents: [{ id: "main" }], })), +})); + +vi.mock("../gateway/session-utils.js", () => ({ + classifySessionKey: vi.fn(() => "direct"), resolveSessionModelRef: vi.fn(() => ({ provider: "openai", model: "gpt-5.2", @@ -61,6 +64,8 @@ vi.mock("../infra/system-events.js", () => ({ })); vi.mock("../routing/session-key.js", () => ({ + normalizeAgentId: vi.fn((value: string) => value), + normalizeMainKey: vi.fn((value?: string) => value ?? "main"), parseAgentSessionKey: vi.fn(() => null), })); diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 6de3b282648..3d151c64772 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -11,11 +11,8 @@ import { resolveStorePath, type SessionEntry, } from "../config/sessions.js"; -import { - classifySessionKey, - listAgentsForGateway, - resolveSessionModelRef, -} from "../gateway/session-utils.js"; +import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; +import { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; @@ -107,7 +104,7 @@ export async function getStatusSummary( resolveLinkChannelContext(cfg), ) : null; - const agentList = listAgentsForGateway(cfg); + const agentList = listGatewayAgentsBasic(cfg); const heartbeatAgents: HeartbeatStatus[] = agentList.agents.map((agent) => { const summary = resolveHeartbeatSummaryForAgent(cfg, agent.id); return { diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index f3dfd37064a..3e68d55ced2 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -168,7 +168,7 @@ const mocks = vi.hoisted(() => ({ configSnapshot: null, }), callGateway: vi.fn().mockResolvedValue({}), - listAgentsForGateway: vi.fn().mockReturnValue({ + listGatewayAgentsBasic: vi.fn().mockReturnValue({ defaultId: "main", mainKey: "agent:main:main", scope: "per-sender", @@ -299,11 +299,18 @@ vi.mock("../gateway/call.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, callGateway: mocks.callGateway }; }); +vi.mock("../gateway/agent-list.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listGatewayAgentsBasic: mocks.listGatewayAgentsBasic, + }; +}); + vi.mock("../gateway/session-utils.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - listAgentsForGateway: mocks.listAgentsForGateway, }; }); vi.mock("../infra/openclaw-root.js", () => ({ @@ -608,11 +615,11 @@ describe("statusCommand", () => { }); it("includes sessions across agents in JSON output", async () => { - const originalAgents = mocks.listAgentsForGateway.getMockImplementation(); + const originalAgents = mocks.listGatewayAgentsBasic.getMockImplementation(); const originalResolveStorePath = mocks.resolveStorePath.getMockImplementation(); const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation(); - mocks.listAgentsForGateway.mockReturnValue({ + mocks.listGatewayAgentsBasic.mockReturnValue({ defaultId: "main", mainKey: "agent:main:main", scope: "per-sender", @@ -651,7 +658,7 @@ describe("statusCommand", () => { ).toBe(true); if (originalAgents) { - mocks.listAgentsForGateway.mockImplementation(originalAgents); + mocks.listGatewayAgentsBasic.mockImplementation(originalAgents); } if (originalResolveStorePath) { mocks.resolveStorePath.mockImplementation(originalResolveStorePath); diff --git a/src/gateway/agent-list.ts b/src/gateway/agent-list.ts new file mode 100644 index 00000000000..d14cdf0c534 --- /dev/null +++ b/src/gateway/agent-list.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import type { SessionScope } from "../config/sessions.js"; +import { normalizeAgentId, normalizeMainKey } from "../routing/session-key.js"; + +export type GatewayAgentListRow = { + id: string; + name?: string; +}; + +function listExistingAgentIdsFromDisk(): string[] { + const root = resolveStateDir(); + const agentsDir = path.join(root, "agents"); + try { + const entries = fs.readdirSync(agentsDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => normalizeAgentId(entry.name)) + .filter(Boolean); + } catch { + return []; + } +} + +function listConfiguredAgentIds(cfg: OpenClawConfig): string[] { + const ids = new Set(); + const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg)); + ids.add(defaultId); + + for (const entry of cfg.agents?.list ?? []) { + if (entry?.id) { + ids.add(normalizeAgentId(entry.id)); + } + } + + for (const id of listExistingAgentIdsFromDisk()) { + ids.add(id); + } + + const sorted = Array.from(ids).filter(Boolean); + sorted.sort((a, b) => a.localeCompare(b)); + return sorted.includes(defaultId) + ? [defaultId, ...sorted.filter((id) => id !== defaultId)] + : sorted; +} + +export function listGatewayAgentsBasic(cfg: OpenClawConfig): { + defaultId: string; + mainKey: string; + scope: SessionScope; + agents: GatewayAgentListRow[]; +} { + const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg)); + const mainKey = normalizeMainKey(cfg.session?.mainKey); + const scope = cfg.session?.scope ?? "per-sender"; + const configuredById = new Map(); + for (const entry of cfg.agents?.list ?? []) { + if (!entry?.id) { + continue; + } + configuredById.set(normalizeAgentId(entry.id), { + name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined, + }); + } + const explicitIds = new Set( + (cfg.agents?.list ?? []) + .map((entry) => (entry?.id ? normalizeAgentId(entry.id) : "")) + .filter(Boolean), + ); + const allowedIds = explicitIds.size > 0 ? new Set([...explicitIds, defaultId]) : null; + let agentIds = listConfiguredAgentIds(cfg).filter((id) => + allowedIds ? allowedIds.has(id) : true, + ); + if (mainKey && !agentIds.includes(mainKey) && (!allowedIds || allowedIds.has(mainKey))) { + agentIds = [...agentIds, mainKey]; + } + const agents = agentIds.map((id) => { + const meta = configuredById.get(id); + return { + id, + name: meta?.name, + }; + }); + return { defaultId, mainKey, scope, agents }; +} From ddd34b6cc3b90e97c3af745b4ef136684d2ab798 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 23:00:16 -0700 Subject: [PATCH 030/133] refactor(plugins): simplify provider auth choice metadata --- docs/concepts/model-providers.md | 4 +- docs/tools/plugin.md | 8 +- extensions/anthropic/index.ts | 19 ++- extensions/openai/openai-codex-provider.ts | 2 + src/cli/models-cli.test.ts | 17 ++- src/cli/models-cli.ts | 7 +- .../auth-choice.apply.plugin-provider.ts | 14 +- src/commands/auth-choice.apply.ts | 11 +- .../auth-choice.preferred-provider.test.ts | 50 +++++++ .../auth-choice.preferred-provider.ts | 30 +++-- ...re.gateway-auth.prompt-auth-config.test.ts | 39 ++++++ src/commands/configure.gateway-auth.ts | 48 +++++-- src/commands/doctor-auth.ts | 41 +++--- src/commands/models/auth.ts | 8 +- src/plugins/provider-validation.test.ts | 25 +++- src/plugins/provider-validation.ts | 124 +++++++++++++----- src/plugins/provider-wizard.test.ts | 36 +++++ src/plugins/provider-wizard.ts | 10 +- src/plugins/types.ts | 20 +++ 19 files changed, 415 insertions(+), 98 deletions(-) create mode 100644 src/commands/auth-choice.preferred-provider.test.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index fc0656c0dd4..6adbb5d0f26 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -42,8 +42,8 @@ Typical split: - `auth[].run` / `auth[].runNonInteractive`: provider owns onboarding/login flows for `openclaw onboard`, `openclaw models auth`, and headless setup -- `wizard.onboarding` / `wizard.modelPicker`: provider owns auth-choice labels, - hints, and setup entries in onboarding/model pickers +- `wizard.setup` / `wizard.modelPicker`: provider owns auth-choice labels, + legacy aliases, onboarding allowlist hints, and setup entries in onboarding/model pickers - `catalog`: provider appears in `models.providers` - `resolveDynamicModel`: provider accepts model ids not present in the local static catalog yet diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index c14f3c39f56..ec4084eeca6 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1275,6 +1275,7 @@ errors instead. - `groupLabel`: group label - `groupHint`: group hint - `methodId`: auth method to run +- `modelAllowlist`: optional post-auth allowlist policy (`allowedKeys`, `initialSelections`, `message`) `wizard.modelPicker` controls how a provider appears as a "set this up now" entry in model selection: @@ -1435,8 +1436,13 @@ Notes: for headless onboarding. - Return `configPatch` when you need to add default models or provider config. - Return `defaultModel` so `--set-default` can update agent defaults. -- `wizard.setup` adds a provider choice to `openclaw onboard`. +- `wizard.setup` adds a provider choice to onboarding surfaces such as + `openclaw onboard` / `openclaw setup --wizard`. +- `wizard.setup.modelAllowlist` lets the provider narrow the follow-up model + allowlist prompt during onboarding/configure. - `wizard.modelPicker` adds a “setup this provider” entry to the model picker. +- `deprecatedProfileIds` lets the provider own `openclaw doctor` cleanup for + retired auth-profile ids. - `discovery.run` returns either `{ provider }` for the plugin’s own provider id or `{ providers }` for multi-provider discovery. - `discovery.order` controls when the provider runs relative to built-in diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index 13758e7de46..a2491dfbd87 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -5,7 +5,11 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { listProfilesForProvider, upsertAuthProfile } from "../../src/agents/auth-profiles.js"; +import { + CLAUDE_CLI_PROFILE_ID, + 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"; @@ -38,6 +42,13 @@ const ANTHROPIC_MODERN_MODEL_PREFIXES = [ "claude-sonnet-4-5", "claude-haiku-4-5", ] as const; +const ANTHROPIC_OAUTH_ALLOWLIST = [ + "anthropic/claude-sonnet-4-6", + "anthropic/claude-opus-4-6", + "anthropic/claude-opus-4-5", + "anthropic/claude-sonnet-4-5", + "anthropic/claude-haiku-4-5", +] as const; function cloneFirstTemplateModel(params: { modelId: string; @@ -309,6 +320,7 @@ const anthropicPlugin = { label: "Anthropic", docsPath: "/providers/models", envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + deprecatedProfileIds: [CLAUDE_CLI_PROFILE_ID], auth: [ { id: "setup-token", @@ -322,6 +334,11 @@ const anthropicPlugin = { groupId: "anthropic", groupLabel: "Anthropic", groupHint: "setup-token + API key", + modelAllowlist: { + allowedKeys: [...ANTHROPIC_OAUTH_ALLOWLIST], + initialSelections: ["anthropic/claude-sonnet-4-6"], + message: "Anthropic OAuth models", + }, }, run: async (ctx: ProviderAuthContext) => await runAnthropicSetupToken(ctx), runNonInteractive: async (ctx) => diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 17ee1348de2..c0ae2c12210 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 { 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"; @@ -194,6 +195,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { id: PROVIDER_ID, label: "OpenAI Codex", docsPath: "/providers/models", + deprecatedProfileIds: [CODEX_CLI_PROFILE_ID], auth: [ { id: "oauth", diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 7386988a1f0..208b74fd09d 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -2,18 +2,17 @@ import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runRegisteredCli } from "../test-utils/command-runner.js"; -const githubCopilotLoginCommand = vi.fn(); const modelsStatusCommand = vi.fn().mockResolvedValue(undefined); const noopAsync = vi.fn(async () => undefined); +const modelsAuthLoginCommand = vi.fn().mockResolvedValue(undefined); vi.mock("../commands/models.js", () => ({ - githubCopilotLoginCommand, modelsStatusCommand, modelsAliasesAddCommand: noopAsync, modelsAliasesListCommand: noopAsync, modelsAliasesRemoveCommand: noopAsync, modelsAuthAddCommand: noopAsync, - modelsAuthLoginCommand: noopAsync, + modelsAuthLoginCommand, modelsAuthOrderClearCommand: noopAsync, modelsAuthOrderGetCommand: noopAsync, modelsAuthOrderSetCommand: noopAsync, @@ -42,7 +41,7 @@ describe("models cli", () => { }); beforeEach(() => { - githubCopilotLoginCommand.mockClear(); + modelsAuthLoginCommand.mockClear(); modelsStatusCommand.mockClear(); }); @@ -74,9 +73,13 @@ describe("models cli", () => { from: "user", }); - expect(githubCopilotLoginCommand).toHaveBeenCalledTimes(1); - expect(githubCopilotLoginCommand).toHaveBeenCalledWith( - expect.objectContaining({ yes: true }), + expect(modelsAuthLoginCommand).toHaveBeenCalledTimes(1); + expect(modelsAuthLoginCommand).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "github-copilot", + method: "device", + yes: true, + }), expect.any(Object), ); }); diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index d3be2d6c131..f3391c2796e 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; import { - githubCopilotLoginCommand, modelsAliasesAddCommand, modelsAliasesListCommand, modelsAliasesRemoveCommand, @@ -364,13 +363,13 @@ export function registerModelsCli(program: Command) { auth .command("login-github-copilot") .description("Login to GitHub Copilot via GitHub device flow (TTY required)") - .option("--profile-id ", "Auth profile id (default: github-copilot:github)") .option("--yes", "Overwrite existing profile without prompting", false) .action(async (opts) => { await runModelsCommand(async () => { - await githubCopilotLoginCommand( + await modelsAuthLoginCommand( { - profileId: opts.profileId as string | undefined, + provider: "github-copilot", + method: "device", yes: Boolean(opts.yes), }, defaultRuntime, diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index 5f4893b249c..76994d27b32 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -117,7 +117,12 @@ export async function applyAuthChoiceLoadedPluginProvider( resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } = await loadPluginProviderRuntime(); - const providers = resolvePluginProviders({ config: params.config, workspaceDir }); + const providers = resolvePluginProviders({ + config: params.config, + workspaceDir, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); const resolved = resolveProviderPluginChoice({ providers, choice: params.authChoice, @@ -190,7 +195,12 @@ export async function applyAuthChoicePluginProvider( const { resolvePluginProviders, runProviderModelSelectedHook } = await loadPluginProviderRuntime(); - const providers = resolvePluginProviders({ config: nextConfig, workspaceDir }); + const providers = resolvePluginProviders({ + config: nextConfig, + workspaceDir, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); const provider = resolveProviderMatch(providers, options.providerId); if (!provider) { await params.prompter.note( diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index 798d8991199..bafa9122e25 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js"; import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js"; import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js"; import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; @@ -28,6 +29,12 @@ export type ApplyAuthChoiceResult = { export async function applyAuthChoice( params: ApplyAuthChoiceParams, ): Promise { + const normalizedAuthChoice = + normalizeLegacyOnboardAuthChoice(params.authChoice) ?? params.authChoice; + const normalizedParams = + normalizedAuthChoice === params.authChoice + ? params + : { ...params, authChoice: normalizedAuthChoice }; const handlers: Array<(p: ApplyAuthChoiceParams) => Promise> = [ applyAuthChoiceLoadedPluginProvider, applyAuthChoiceAnthropic, @@ -38,11 +45,11 @@ export async function applyAuthChoice( ]; for (const handler of handlers) { - const result = await handler(params); + const result = await handler(normalizedParams); if (result) { return result; } } - return { config: params.config }; + return { config: normalizedParams.config }; } diff --git a/src/commands/auth-choice.preferred-provider.test.ts b/src/commands/auth-choice.preferred-provider.test.ts new file mode 100644 index 00000000000..6f84763a308 --- /dev/null +++ b/src/commands/auth-choice.preferred-provider.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveProviderPluginChoice = vi.hoisted(() => vi.fn()); +const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); + +vi.mock("../plugins/provider-wizard.js", () => ({ + resolveProviderPluginChoice, +})); + +vi.mock("../plugins/providers.js", () => ({ + resolvePluginProviders, +})); + +import { resolvePreferredProviderForAuthChoice } from "./auth-choice.preferred-provider.js"; + +describe("resolvePreferredProviderForAuthChoice", () => { + beforeEach(() => { + vi.clearAllMocks(); + resolvePluginProviders.mockReturnValue([]); + resolveProviderPluginChoice.mockReturnValue(null); + }); + + it("normalizes legacy auth choices before plugin lookup", async () => { + resolveProviderPluginChoice.mockReturnValue({ + provider: { id: "anthropic", label: "Anthropic", auth: [] }, + method: { id: "setup-token", label: "setup-token", kind: "token" }, + }); + + await expect(resolvePreferredProviderForAuthChoice({ choice: "claude-cli" })).resolves.toBe( + "anthropic", + ); + expect(resolveProviderPluginChoice).toHaveBeenCalledWith( + expect.objectContaining({ + choice: "setup-token", + }), + ); + expect(resolvePluginProviders).toHaveBeenCalledWith( + expect.objectContaining({ + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }), + ); + }); + + it("falls back to static core choices when no provider plugin claims the choice", async () => { + await expect(resolvePreferredProviderForAuthChoice({ choice: "chutes" })).resolves.toBe( + "chutes", + ); + }); +}); diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 49251a88f87..a7faad5d3a4 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -1,15 +1,12 @@ import type { OpenClawConfig } from "../config/config.js"; +import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js"; import type { AuthChoice } from "./onboard-types.js"; const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { - oauth: "anthropic", - "setup-token": "anthropic", - "claude-cli": "anthropic", + chutes: "chutes", token: "anthropic", apiKey: "anthropic", "openai-codex": "openai-codex", - "codex-cli": "openai-codex", - chutes: "chutes", "openai-api-key": "openai", "openrouter-api-key": "openrouter", "kilocode-api-key": "kilocode", @@ -57,11 +54,7 @@ export async function resolvePreferredProviderForAuthChoice(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): Promise { - const preferred = PREFERRED_PROVIDER_BY_AUTH_CHOICE[params.choice]; - if (preferred) { - return preferred; - } - + const choice = normalizeLegacyOnboardAuthChoice(params.choice) ?? params.choice; const [{ resolveProviderPluginChoice }, { resolvePluginProviders }] = await Promise.all([ import("../plugins/provider-wizard.js"), import("../plugins/providers.js"), @@ -70,9 +63,20 @@ export async function resolvePreferredProviderForAuthChoice(params: { config: params.config, workspaceDir: params.workspaceDir, env: params.env, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, }); - return resolveProviderPluginChoice({ + const pluginResolved = resolveProviderPluginChoice({ providers, - choice: params.choice, - })?.provider.id; + choice, + }); + if (pluginResolved) { + return pluginResolved.provider.id; + } + + const preferred = PREFERRED_PROVIDER_BY_AUTH_CHOICE[choice]; + if (preferred) { + return preferred; + } + return undefined; } diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index 0657a77b3e1..b6ba81a432e 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ promptModelAllowlist: vi.fn(), promptDefaultModel: vi.fn(), promptCustomApiConfig: vi.fn(), + resolvePluginProviders: vi.fn(() => []), + resolveProviderPluginChoice: vi.fn<() => unknown>(() => null), })); vi.mock("../agents/auth-profiles.js", () => ({ @@ -39,6 +41,14 @@ vi.mock("./onboard-custom.js", () => ({ promptCustomApiConfig: mocks.promptCustomApiConfig, })); +vi.mock("../plugins/providers.js", () => ({ + resolvePluginProviders: mocks.resolvePluginProviders, +})); + +vi.mock("../plugins/provider-wizard.js", () => ({ + resolveProviderPluginChoice: mocks.resolveProviderPluginChoice, +})); + import { promptAuthConfig } from "./configure.gateway-auth.js"; function makeRuntime(): RuntimeEnv { @@ -94,6 +104,8 @@ async function runPromptAuthConfigWithAllowlist(includeMinimaxProvider = false) mocks.promptModelAllowlist.mockResolvedValue({ models: ["kilocode/kilo/auto"], }); + mocks.resolvePluginProviders.mockReturnValue([]); + mocks.resolveProviderPluginChoice.mockReturnValue(null); return promptAuthConfig({}, makeRuntime(), noopPrompter); } @@ -118,4 +130,31 @@ describe("promptAuthConfig", () => { "MiniMax-M2.5", ]); }); + + it("uses plugin-owned allowlist metadata for provider auth choices", async () => { + mocks.promptAuthChoiceGrouped.mockResolvedValue("token"); + mocks.applyAuthChoice.mockResolvedValue({ config: {} }); + mocks.promptModelAllowlist.mockResolvedValue({ models: undefined }); + mocks.resolveProviderPluginChoice.mockReturnValue({ + provider: { id: "anthropic", label: "Anthropic", auth: [] }, + method: { id: "setup-token", label: "setup-token", kind: "token" }, + wizard: { + modelAllowlist: { + allowedKeys: ["anthropic/claude-sonnet-4-6"], + initialSelections: ["anthropic/claude-sonnet-4-6"], + message: "Anthropic OAuth models", + }, + }, + }); + + await promptAuthConfig({}, makeRuntime(), noopPrompter); + + expect(mocks.promptModelAllowlist).toHaveBeenCalledWith( + expect.objectContaining({ + allowedKeys: ["anthropic/claude-sonnet-4-6"], + initialSelections: ["anthropic/claude-sonnet-4-6"], + message: "Anthropic OAuth models", + }), + ); + }); }); diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index ca56ee25275..8963557e80a 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -2,6 +2,8 @@ import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import type { OpenClawConfig, GatewayAuthConfig } from "../config/config.js"; import { isSecretRef, type SecretInput } from "../config/types.secrets.js"; +import { resolveProviderPluginChoice } from "../plugins/provider-wizard.js"; +import { resolvePluginProviders } from "../plugins/providers.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js"; @@ -30,13 +32,30 @@ function sanitizeTokenValue(value: unknown): string | undefined { return trimmed; } -const ANTHROPIC_OAUTH_MODEL_KEYS = [ - "anthropic/claude-sonnet-4-6", - "anthropic/claude-opus-4-6", - "anthropic/claude-opus-4-5", - "anthropic/claude-sonnet-4-5", - "anthropic/claude-haiku-4-5", -]; +function resolveProviderChoiceModelAllowlist(params: { + authChoice: string; + config: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): + | { + allowedKeys?: string[]; + initialSelections?: string[]; + message?: string; + } + | undefined { + const providers = resolvePluginProviders({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); + return resolveProviderPluginChoice({ + providers, + choice: params.authChoice, + })?.wizard?.modelAllowlist; +} export function buildGatewayAuthConfig(params: { existing?: GatewayAuthConfig; @@ -125,16 +144,19 @@ export async function promptAuthConfig( } } - const anthropicOAuth = - authChoice === "setup-token" || authChoice === "token" || authChoice === "oauth"; - if (authChoice !== "custom-api-key") { + const modelAllowlist = resolveProviderChoiceModelAllowlist({ + authChoice, + config: next, + workspaceDir: resolveDefaultAgentWorkspaceDir(), + env: process.env, + }); const allowlistSelection = await promptModelAllowlist({ config: next, prompter, - allowedKeys: anthropicOAuth ? ANTHROPIC_OAUTH_MODEL_KEYS : undefined, - initialSelections: anthropicOAuth ? ["anthropic/claude-sonnet-4-6"] : undefined, - message: anthropicOAuth ? "Anthropic OAuth models" : undefined, + allowedKeys: modelAllowlist?.allowedKeys, + initialSelections: modelAllowlist?.initialSelections, + message: modelAllowlist?.message, }); if (allowlistSelection.models) { next = applyModelAllowlist(next, allowlistSelection.models); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index cf8267cebff..1f46ef28ba1 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -15,6 +15,7 @@ import { import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; +import { resolvePluginProviders } from "../plugins/providers.js"; import { note } from "../terminal/note.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; import { @@ -119,30 +120,36 @@ export async function maybeRemoveDeprecatedCliAuthProfiles( prompter: DoctorPrompter, ): Promise { const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false }); - const deprecated = new Set(); - if (store.profiles[CLAUDE_CLI_PROFILE_ID] || cfg.auth?.profiles?.[CLAUDE_CLI_PROFILE_ID]) { - deprecated.add(CLAUDE_CLI_PROFILE_ID); - } - if (store.profiles[CODEX_CLI_PROFILE_ID] || cfg.auth?.profiles?.[CODEX_CLI_PROFILE_ID]) { - deprecated.add(CODEX_CLI_PROFILE_ID); - } + const providers = resolvePluginProviders({ + config: cfg, + env: process.env, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); + const deprecatedEntries = providers.flatMap((provider) => + (provider.deprecatedProfileIds ?? []) + .filter((profileId) => store.profiles[profileId] || cfg.auth?.profiles?.[profileId]) + .map((profileId) => ({ + profileId, + providerId: provider.id, + providerLabel: provider.label, + })), + ); + const deprecated = new Set(deprecatedEntries.map((entry) => entry.profileId)); if (deprecated.size === 0) { return cfg; } const lines = ["Deprecated external CLI auth profiles detected (no longer supported):"]; - if (deprecated.has(CLAUDE_CLI_PROFILE_ID)) { + for (const entry of deprecatedEntries) { const authCommand = - resolveProviderAuthLoginCommand({ provider: "anthropic" }) ?? - formatCliCommand("openclaw configure"); - lines.push(`- ${CLAUDE_CLI_PROFILE_ID} (Anthropic): use ${authCommand}`); - } - if (deprecated.has(CODEX_CLI_PROFILE_ID)) { - const authCommand = - resolveProviderAuthLoginCommand({ provider: "openai-codex" }) ?? - formatCliCommand("openclaw configure"); - lines.push(`- ${CODEX_CLI_PROFILE_ID} (OpenAI Codex): use ${authCommand}`); + resolveProviderAuthLoginCommand({ + provider: entry.providerId, + config: cfg, + env: process.env, + }) ?? formatCliCommand("openclaw configure"); + lines.push(`- ${entry.profileId} (${entry.providerLabel}): use ${authCommand}`); } note(lines.join("\n"), "Auth profiles"); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 1ea838fdb27..6001ede2ea4 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -106,7 +106,12 @@ async function resolveModelsAuthContext(): Promise { const agentDir = resolveAgentDir(config, defaultAgentId); const workspaceDir = resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir(); - const providers = resolvePluginProviders({ config, workspaceDir }); + const providers = resolvePluginProviders({ + config, + workspaceDir, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); return { config, agentDir, workspaceDir, providers }; } @@ -490,6 +495,7 @@ type LoginOptions = { provider?: string; method?: string; setDefault?: boolean; + yes?: boolean; }; /** diff --git a/src/plugins/provider-validation.test.ts b/src/plugins/provider-validation.test.ts index f0208aa1d2a..fe934aa6578 100644 --- a/src/plugins/provider-validation.test.ts +++ b/src/plugins/provider-validation.test.ts @@ -32,12 +32,21 @@ describe("normalizeRegisteredProvider", () => { id: " demo ", label: " Demo Provider ", aliases: [" alias-one ", "alias-one", ""], + deprecatedProfileIds: [" demo:legacy ", "demo:legacy", ""], envVars: [" DEMO_API_KEY ", "DEMO_API_KEY"], auth: [ { id: " primary ", label: " Primary ", kind: "custom", + wizard: { + choiceId: " demo-primary ", + modelAllowlist: { + allowedKeys: [" demo/model ", "demo/model"], + initialSelections: [" demo/model "], + message: " Demo models ", + }, + }, run: async () => ({ profiles: [] }), }, { @@ -66,8 +75,22 @@ describe("normalizeRegisteredProvider", () => { id: "demo", label: "Demo Provider", aliases: ["alias-one"], + deprecatedProfileIds: ["demo:legacy"], envVars: ["DEMO_API_KEY"], - auth: [{ id: "primary", label: "Primary" }], + auth: [ + { + id: "primary", + label: "Primary", + wizard: { + choiceId: "demo-primary", + modelAllowlist: { + allowedKeys: ["demo/model"], + initialSelections: ["demo/model"], + message: "Demo models", + }, + }, + }, + ], wizard: { setup: { choiceId: "demo-choice", diff --git a/src/plugins/provider-validation.ts b/src/plugins/provider-validation.ts index 172fefc1777..f53abc8bd6d 100644 --- a/src/plugins/provider-validation.ts +++ b/src/plugins/provider-validation.ts @@ -27,6 +27,80 @@ function normalizeTextList(values: string[] | undefined): string[] | undefined { return normalized.length > 0 ? normalized : undefined; } +function normalizeProviderWizardSetup(params: { + providerId: string; + pluginId: string; + source: string; + auth: ProviderAuthMethod[]; + setup: NonNullable["setup"]; + pushDiagnostic: (diag: PluginDiagnostic) => void; +}): NonNullable["setup"] { + const hasAuthMethods = params.auth.length > 0; + if (!params.setup) { + return undefined; + } + if (!hasAuthMethods) { + pushProviderDiagnostic({ + level: "warn", + pluginId: params.pluginId, + source: params.source, + message: `provider "${params.providerId}" setup metadata ignored because it has no auth methods`, + pushDiagnostic: params.pushDiagnostic, + }); + return undefined; + } + const methodId = normalizeText(params.setup.methodId); + if (methodId && !params.auth.some((method) => method.id === methodId)) { + pushProviderDiagnostic({ + level: "warn", + pluginId: params.pluginId, + source: params.source, + message: `provider "${params.providerId}" setup method "${methodId}" not found; falling back to available methods`, + pushDiagnostic: params.pushDiagnostic, + }); + } + return { + ...(normalizeText(params.setup.choiceId) + ? { choiceId: normalizeText(params.setup.choiceId) } + : {}), + ...(normalizeText(params.setup.choiceLabel) + ? { choiceLabel: normalizeText(params.setup.choiceLabel) } + : {}), + ...(normalizeText(params.setup.choiceHint) + ? { choiceHint: normalizeText(params.setup.choiceHint) } + : {}), + ...(normalizeText(params.setup.groupId) + ? { groupId: normalizeText(params.setup.groupId) } + : {}), + ...(normalizeText(params.setup.groupLabel) + ? { groupLabel: normalizeText(params.setup.groupLabel) } + : {}), + ...(normalizeText(params.setup.groupHint) + ? { groupHint: normalizeText(params.setup.groupHint) } + : {}), + ...(methodId && params.auth.some((method) => method.id === methodId) ? { methodId } : {}), + ...(params.setup.modelAllowlist + ? { + modelAllowlist: { + ...(normalizeTextList(params.setup.modelAllowlist.allowedKeys) + ? { allowedKeys: normalizeTextList(params.setup.modelAllowlist.allowedKeys) } + : {}), + ...(normalizeTextList(params.setup.modelAllowlist.initialSelections) + ? { + initialSelections: normalizeTextList( + params.setup.modelAllowlist.initialSelections, + ), + } + : {}), + ...(normalizeText(params.setup.modelAllowlist.message) + ? { message: normalizeText(params.setup.modelAllowlist.message) } + : {}), + }, + } + : {}), + }; +} + function normalizeProviderAuthMethods(params: { providerId: string; pluginId: string; @@ -60,11 +134,20 @@ function normalizeProviderAuthMethods(params: { continue; } seenMethodIds.add(methodId); + const wizard = normalizeProviderWizardSetup({ + providerId: params.providerId, + pluginId: params.pluginId, + source: params.source, + auth: [{ ...method, id: methodId }], + setup: method.wizard, + pushDiagnostic: params.pushDiagnostic, + }); normalized.push({ ...method, id: methodId, label: normalizeText(method.label) ?? methodId, ...(normalizeText(method.hint) ? { hint: normalizeText(method.hint) } : {}), + ...(wizard ? { wizard } : {}), }); } @@ -92,37 +175,14 @@ function normalizeProviderWizard(params: { if (!setup) { return undefined; } - if (!hasAuthMethods) { - pushProviderDiagnostic({ - level: "warn", - pluginId: params.pluginId, - source: params.source, - message: `provider "${params.providerId}" setup metadata ignored because it has no auth methods`, - pushDiagnostic: params.pushDiagnostic, - }); - return undefined; - } - const methodId = normalizeText(setup.methodId); - if (methodId && !hasMethod(methodId)) { - pushProviderDiagnostic({ - level: "warn", - pluginId: params.pluginId, - source: params.source, - message: `provider "${params.providerId}" setup method "${methodId}" not found; falling back to available methods`, - pushDiagnostic: params.pushDiagnostic, - }); - } - return { - ...(normalizeText(setup.choiceId) ? { choiceId: normalizeText(setup.choiceId) } : {}), - ...(normalizeText(setup.choiceLabel) - ? { choiceLabel: normalizeText(setup.choiceLabel) } - : {}), - ...(normalizeText(setup.choiceHint) ? { choiceHint: normalizeText(setup.choiceHint) } : {}), - ...(normalizeText(setup.groupId) ? { groupId: normalizeText(setup.groupId) } : {}), - ...(normalizeText(setup.groupLabel) ? { groupLabel: normalizeText(setup.groupLabel) } : {}), - ...(normalizeText(setup.groupHint) ? { groupHint: normalizeText(setup.groupHint) } : {}), - ...(methodId && hasMethod(methodId) ? { methodId } : {}), - }; + return normalizeProviderWizardSetup({ + providerId: params.providerId, + pluginId: params.pluginId, + source: params.source, + auth: params.auth, + setup, + pushDiagnostic: params.pushDiagnostic, + }); }; const normalizeModelPicker = () => { @@ -195,6 +255,7 @@ export function normalizeRegisteredProvider(params: { }); const docsPath = normalizeText(params.provider.docsPath); const aliases = normalizeTextList(params.provider.aliases); + const deprecatedProfileIds = normalizeTextList(params.provider.deprecatedProfileIds); const envVars = normalizeTextList(params.provider.envVars); const wizard = normalizeProviderWizard({ providerId: id, @@ -230,6 +291,7 @@ export function normalizeRegisteredProvider(params: { label: normalizeText(params.provider.label) ?? id, ...(docsPath ? { docsPath } : {}), ...(aliases ? { aliases } : {}), + ...(deprecatedProfileIds ? { deprecatedProfileIds } : {}), ...(envVars ? { envVars } : {}), auth, ...(catalog ? { catalog } : {}), diff --git a/src/plugins/provider-wizard.test.ts b/src/plugins/provider-wizard.test.ts index eff361ee1c9..d7b8348f9b2 100644 --- a/src/plugins/provider-wizard.test.ts +++ b/src/plugins/provider-wizard.test.ts @@ -61,6 +61,7 @@ describe("provider wizard boundaries", () => { ).toEqual({ provider, method: provider.auth[0], + wizard: provider.wizard?.setup, }); }); @@ -101,6 +102,41 @@ describe("provider wizard boundaries", () => { ).toEqual({ provider, method: provider.auth[0], + wizard: provider.auth[0]?.wizard, + }); + }); + + it("returns method wizard metadata for canonical choices", () => { + const provider = makeProvider({ + id: "anthropic", + label: "Anthropic", + auth: [ + { + id: "setup-token", + label: "setup-token", + kind: "token", + wizard: { + choiceId: "token", + modelAllowlist: { + allowedKeys: ["anthropic/claude-sonnet-4-6"], + initialSelections: ["anthropic/claude-sonnet-4-6"], + message: "Anthropic OAuth models", + }, + }, + run: vi.fn(), + }, + ], + }); + + expect( + resolveProviderPluginChoice({ + providers: [provider], + choice: "token", + }), + ).toEqual({ + provider, + method: provider.auth[0], + wizard: provider.auth[0]?.wizard, }); }); diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index cbe90178056..0b95a07f2b5 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -190,7 +190,11 @@ export function resolveProviderModelPickerEntries(params: { export function resolveProviderPluginChoice(params: { providers: ProviderPlugin[]; choice: string; -}): { provider: ProviderPlugin; method: ProviderAuthMethod } | null { +}): { + provider: ProviderPlugin; + method: ProviderAuthMethod; + wizard?: ProviderPluginWizardSetup; +} | null { const choice = params.choice.trim(); if (!choice) { return null; @@ -216,7 +220,7 @@ export function resolveProviderPluginChoice(params: { const choiceId = wizard.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, method.id); if (normalizeChoiceId(choiceId) === choice) { - return { provider, method }; + return { provider, method, wizard }; } } const setup = provider.wizard?.setup; @@ -225,7 +229,7 @@ export function resolveProviderPluginChoice(params: { if (normalizeChoiceId(setupChoiceId) === choice) { const method = resolveMethodById(provider, setup.methodId); if (method) { - return { provider, method }; + return { provider, method, wizard: setup }; } } } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index f533b1b80a1..6dc5788b9eb 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -553,6 +553,18 @@ export type ProviderPluginWizardSetup = { groupLabel?: string; groupHint?: string; methodId?: string; + /** + * Optional model-allowlist prompt policy applied after this auth choice is + * selected in configure/onboarding flows. + * + * Keep this UI-facing and static. Provider logic that needs runtime state + * should stay in `run`/`runNonInteractive`. + */ + modelAllowlist?: { + allowedKeys?: string[]; + initialSelections?: string[]; + message?: string; + }; }; export type ProviderPluginWizardModelPicker = { @@ -773,6 +785,14 @@ export type ProviderPlugin = { * bearer token (for example Gemini CLI's `{ token, projectId }` payload). */ formatApiKey?: (cred: AuthProfileCredential) => string; + /** + * Legacy auth-profile ids that should be retired by `openclaw doctor`. + * + * Use this when a provider plugin replaces an older core-managed profile id + * and wants cleanup/migration messaging to live with the provider instead of + * in hardcoded doctor tables. + */ + deprecatedProfileIds?: string[]; /** * Provider-owned OAuth refresh. * From aa97368f7d6f5ed1e805ec739b13871b6008226f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 23:02:36 -0700 Subject: [PATCH 031/133] test: add openshell sandbox e2e smoke --- docs/help/testing.md | 19 +- package.json | 1 + test/openshell-sandbox.e2e.test.ts | 585 +++++++++++++++++++++++++++++ 3 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 test/openshell-sandbox.e2e.test.ts diff --git a/docs/help/testing.md b/docs/help/testing.md index b2057e8a1da..9fa1404a8d4 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -61,7 +61,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - Command: `pnpm test:e2e` - Config: `vitest.e2e.config.ts` -- Files: `src/**/*.e2e.test.ts` +- Files: `src/**/*.e2e.test.ts`, `test/**/*.e2e.test.ts` - Runtime defaults: - Uses Vitest `vmForks` for faster file startup. - Uses adaptive workers (CI: 2-4, local: 4-8). @@ -77,6 +77,23 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - No real keys required - More moving parts than unit tests (can be slower) +### E2E: OpenShell backend smoke + +- Command: `pnpm test:e2e:openshell` +- File: `test/openshell-sandbox.e2e.test.ts` +- Scope: + - Starts an isolated OpenShell gateway on the host via Docker + - Creates a sandbox from a temporary local Dockerfile + - Exercises OpenClaw's OpenShell backend over real `sandbox ssh-config` + SSH exec + - Verifies remote-canonical filesystem behavior through the sandbox fs bridge +- Expectations: + - Opt-in only; not part of the default `pnpm test:e2e` run + - Requires a local `openshell` CLI plus a working Docker daemon + - Uses isolated `HOME` / `XDG_CONFIG_HOME`, then destroys the test gateway and sandbox +- Useful overrides: + - `OPENCLAW_E2E_OPENSHELL=1` to enable the test when running the broader e2e suite manually + - `OPENCLAW_E2E_OPENSHELL_COMMAND=/path/to/openshell` to point at a non-default CLI binary or wrapper script + ### Live (real providers + real models) - Command: `pnpm test:live` diff --git a/package.json b/package.json index 6aa553f5302..124975e63d1 100644 --- a/package.json +++ b/package.json @@ -319,6 +319,7 @@ "test:docker:plugins": "bash scripts/e2e/plugins-docker.sh", "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 vitest run --config vitest.e2e.config.ts test/openshell-sandbox.e2e.test.ts", "test:extensions": "vitest run --config vitest.extensions.config.ts", "test:fast": "vitest run --config vitest.unit.config.ts", "test:force": "node --import tsx scripts/test-force.ts", diff --git a/test/openshell-sandbox.e2e.test.ts b/test/openshell-sandbox.e2e.test.ts new file mode 100644 index 00000000000..21824db38ee --- /dev/null +++ b/test/openshell-sandbox.e2e.test.ts @@ -0,0 +1,585 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createOpenShellSandboxBackendFactory } from "../extensions/openshell/src/backend.js"; +import { resolveOpenShellPluginConfig } from "../extensions/openshell/src/config.js"; +import { createSandboxTestContext } from "../src/agents/sandbox/test-fixtures.js"; + +const OPENCLAW_OPENSHELL_E2E = process.env.OPENCLAW_E2E_OPENSHELL === "1"; +const OPENCLAW_OPENSHELL_E2E_TIMEOUT_MS = 12 * 60_000; +const OPENCLAW_OPENSHELL_COMMAND = + process.env.OPENCLAW_E2E_OPENSHELL_COMMAND?.trim() || "openshell"; + +const CUSTOM_IMAGE_DOCKERFILE = `FROM python:3.13-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \\ + coreutils \\ + curl \\ + findutils \\ + iproute2 \\ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 sandbox && \\ + useradd -m -u 1000 -g sandbox sandbox + +RUN echo "openclaw-openshell-e2e" > /opt/openshell-e2e-marker.txt + +WORKDIR /sandbox +CMD ["sleep", "infinity"] +`; + +type ExecResult = { + code: number; + stdout: string; + stderr: string; +}; + +type HostPolicyServer = { + port: number; + close(): Promise; +}; + +async function runCommand(params: { + command: string; + args: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + stdin?: string | Buffer; + allowFailure?: boolean; + timeoutMs?: number; +}): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(params.command, params.args, { + cwd: params.cwd, + env: params.env, + stdio: ["pipe", "pipe", "pipe"], + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let timedOut = false; + const timeout = + params.timeoutMs && params.timeoutMs > 0 + ? setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, params.timeoutMs) + : null; + + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on("error", reject); + child.on("close", (code) => { + if (timeout) { + clearTimeout(timeout); + } + const stdout = Buffer.concat(stdoutChunks).toString("utf8"); + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + if (timedOut) { + reject(new Error(`command timed out: ${params.command} ${params.args.join(" ")}`)); + return; + } + const exitCode = code ?? 0; + if (exitCode !== 0 && !params.allowFailure) { + reject( + new Error( + [ + `command failed: ${params.command} ${params.args.join(" ")}`, + `exit: ${exitCode}`, + stdout.trim() ? `stdout:\n${stdout}` : "", + stderr.trim() ? `stderr:\n${stderr}` : "", + ] + .filter(Boolean) + .join("\n"), + ), + ); + return; + } + resolve({ code: exitCode, stdout, stderr }); + }); + + child.stdin.end(params.stdin); + }); +} + +async function commandAvailable(command: string): Promise { + try { + const result = await runCommand({ + command, + args: ["--help"], + allowFailure: true, + timeoutMs: 20_000, + }); + return result.code === 0 || result.stdout.length > 0 || result.stderr.length > 0; + } catch { + return false; + } +} + +async function dockerReady(): Promise { + try { + const result = await runCommand({ + command: "docker", + args: ["version"], + allowFailure: true, + timeoutMs: 20_000, + }); + return result.code === 0; + } catch { + return false; + } +} + +async function allocatePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("failed to allocate local port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + }); +} + +function openshellEnv(rootDir: string): NodeJS.ProcessEnv { + const homeDir = path.join(rootDir, "home"); + const xdgDir = path.join(rootDir, "xdg"); + const cacheDir = path.join(rootDir, "xdg-cache"); + return { + ...process.env, + HOME: homeDir, + XDG_CONFIG_HOME: xdgDir, + XDG_CACHE_HOME: cacheDir, + }; +} + +function trimTrailingNewline(value: string): string { + return value.replace(/\r?\n$/, ""); +} + +async function startHostPolicyServer(): Promise { + const port = await allocatePort(); + const responseBody = JSON.stringify({ ok: true, message: "hello-from-host" }); + const serverScript = `from http.server import BaseHTTPRequestHandler, HTTPServer +import os + +BODY = os.environ["RESPONSE_BODY"].encode() + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(BODY))) + self.end_headers() + self.wfile.write(BODY) + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + if length: + self.rfile.read(length) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(BODY))) + self.end_headers() + self.wfile.write(BODY) + + def log_message(self, _format, *_args): + pass + +HTTPServer(("0.0.0.0", 8000), Handler).serve_forever() +`; + const startResult = await runCommand({ + command: "docker", + args: [ + "run", + "--detach", + "--rm", + "-e", + `RESPONSE_BODY=${responseBody}`, + "-p", + `${port}:8000`, + "python:3.13-alpine", + "python3", + "-c", + serverScript, + ], + timeoutMs: 60_000, + }); + const containerId = trimTrailingNewline(startResult.stdout.trim()); + if (!containerId) { + throw new Error("failed to start docker-backed host policy server"); + } + + const startedAt = Date.now(); + while (Date.now() - startedAt < 30_000) { + const readyResult = await runCommand({ + command: "docker", + args: [ + "exec", + containerId, + "python3", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000', timeout=1).read()", + ], + allowFailure: true, + timeoutMs: 15_000, + }); + if (readyResult.code === 0) { + return { + port, + async close() { + await runCommand({ + command: "docker", + args: ["rm", "-f", containerId], + allowFailure: true, + timeoutMs: 30_000, + }); + }, + }; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + await runCommand({ + command: "docker", + args: ["rm", "-f", containerId], + allowFailure: true, + timeoutMs: 30_000, + }); + throw new Error("docker-backed host policy server did not become ready"); +} + +function buildOpenShellPolicyYaml(params: { port: number; binaryPath: string }): string { + const networkPolicies = ` host_echo: + name: host-echo + endpoints: + - host: host.openshell.internal + port: ${params.port} + allowed_ips: + - "0.0.0.0/0" + binaries: + - path: ${params.binaryPath}`; + return `version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: +${networkPolicies} +`; +} + +async function runBackendExec(params: { + backend: Awaited>>; + command: string; + allowFailure?: boolean; + timeoutMs?: number; +}): Promise { + const execSpec = await params.backend.buildExecSpec({ + command: params.command, + env: {}, + usePty: false, + }); + let result: ExecResult | null = null; + try { + result = await runCommand({ + command: execSpec.argv[0] ?? "ssh", + args: execSpec.argv.slice(1), + env: execSpec.env, + allowFailure: params.allowFailure, + timeoutMs: params.timeoutMs, + }); + return result; + } finally { + await params.backend.finalizeExec?.({ + status: result?.code === 0 ? "completed" : "failed", + exitCode: result?.code ?? 1, + timedOut: false, + token: execSpec.finalizeToken, + }); + } +} + +describe("openshell sandbox backend e2e", () => { + it.runIf(process.platform !== "win32" && OPENCLAW_OPENSHELL_E2E)( + "creates a remote-canonical sandbox through OpenShell and executes over SSH", + { timeout: OPENCLAW_OPENSHELL_E2E_TIMEOUT_MS }, + async () => { + if (!(await dockerReady())) { + return; + } + if (!(await commandAvailable(OPENCLAW_OPENSHELL_COMMAND))) { + return; + } + + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-openshell-e2e-")); + const env = openshellEnv(rootDir); + const previousHome = process.env.HOME; + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + const previousXdgCacheHome = process.env.XDG_CACHE_HOME; + const workspaceDir = path.join(rootDir, "workspace"); + const dockerfileDir = path.join(rootDir, "custom-image"); + const dockerfilePath = path.join(dockerfileDir, "Dockerfile"); + const denyPolicyPath = path.join(rootDir, "deny-policy.yaml"); + const allowPolicyPath = path.join(rootDir, "allow-policy.yaml"); + const scopeSuffix = `${process.pid}-${Date.now()}`; + const gatewayName = `openclaw-e2e-${scopeSuffix}`; + const scopeKey = `session:openshell-e2e-deny:${scopeSuffix}`; + const allowSandboxName = `openclaw-policy-allow-${scopeSuffix}`; + const gatewayPort = await allocatePort(); + let hostPolicyServer: HostPolicyServer | null = null; + const sandboxCfg = { + mode: "all" as const, + backend: "openshell" as const, + scope: "session" as const, + workspaceAccess: "rw" as const, + workspaceRoot: path.join(rootDir, "sandboxes"), + docker: { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], + env: {}, + }, + ssh: { + command: "ssh", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + browser: { + enabled: false, + image: "openclaw-browser", + containerPrefix: "openclaw-browser-", + network: "bridge", + cdpPort: 9222, + vncPort: 5900, + noVncPort: 6080, + headless: true, + enableNoVnc: false, + allowHostControl: false, + autoStart: false, + autoStartTimeoutMs: 1000, + }, + tools: { allow: [], deny: [] }, + prune: { idleHours: 24, maxAgeDays: 7 }, + }; + + const pluginConfig = resolveOpenShellPluginConfig({ + command: OPENCLAW_OPENSHELL_COMMAND, + gateway: gatewayName, + from: dockerfilePath, + mode: "remote", + autoProviders: false, + policy: denyPolicyPath, + }); + const backendFactory = createOpenShellSandboxBackendFactory({ pluginConfig }); + const backend = await backendFactory({ + sessionKey: scopeKey, + scopeKey, + workspaceDir, + agentWorkspaceDir: workspaceDir, + cfg: sandboxCfg, + }); + + try { + process.env.HOME = env.HOME; + process.env.XDG_CONFIG_HOME = env.XDG_CONFIG_HOME; + process.env.XDG_CACHE_HOME = env.XDG_CACHE_HOME; + hostPolicyServer = await startHostPolicyServer(); + if (!hostPolicyServer) { + throw new Error("failed to start host policy server"); + } + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.mkdir(dockerfileDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed-from-local\n", "utf8"); + await fs.writeFile(dockerfilePath, CUSTOM_IMAGE_DOCKERFILE, "utf8"); + await fs.writeFile( + denyPolicyPath, + buildOpenShellPolicyYaml({ + port: hostPolicyServer.port, + binaryPath: "/usr/bin/false", + }), + "utf8", + ); + await fs.writeFile( + allowPolicyPath, + buildOpenShellPolicyYaml({ + port: hostPolicyServer.port, + binaryPath: "/**", + }), + "utf8", + ); + + await runCommand({ + command: OPENCLAW_OPENSHELL_COMMAND, + args: [ + "gateway", + "start", + "--name", + gatewayName, + "--port", + String(gatewayPort), + "--recreate", + ], + env, + timeoutMs: 8 * 60_000, + }); + + const execResult = await runBackendExec({ + backend, + command: "pwd && cat /opt/openshell-e2e-marker.txt && cat seed.txt", + timeoutMs: 2 * 60_000, + }); + + expect(execResult.code).toBe(0); + const stdout = execResult.stdout.trim(); + expect(stdout).toContain("/sandbox"); + expect(stdout).toContain("openclaw-openshell-e2e"); + expect(stdout).toContain("seed-from-local"); + + const curlPathResult = await runBackendExec({ + backend, + command: "command -v curl", + timeoutMs: 60_000, + }); + expect(trimTrailingNewline(curlPathResult.stdout.trim())).toMatch(/^\/.+\/curl$/); + + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + runtimeId: backend.runtimeId, + runtimeLabel: backend.runtimeLabel, + containerName: backend.runtimeId, + containerWorkdir: backend.workdir, + backend, + }, + }); + const bridge = backend.createFsBridge?.({ sandbox }); + if (!bridge) { + throw new Error("openshell backend did not create a filesystem bridge"); + } + + await bridge.writeFile({ filePath: "nested/remote-only.txt", data: "hello-remote\n" }); + await expect( + fs.readFile(path.join(workspaceDir, "nested", "remote-only.txt"), "utf8"), + ).rejects.toThrow(); + await expect(bridge.readFile({ filePath: "nested/remote-only.txt" })).resolves.toEqual( + Buffer.from("hello-remote\n"), + ); + + const verifyResult = await runCommand({ + command: OPENCLAW_OPENSHELL_COMMAND, + args: ["sandbox", "ssh-config", backend.runtimeId], + env, + timeoutMs: 60_000, + }); + expect(verifyResult.code).toBe(0); + expect(trimTrailingNewline(verifyResult.stdout)).toContain("Host "); + + const blockedGetResult = await runBackendExec({ + backend, + command: `curl --fail --silent --show-error --max-time 15 "http://host.openshell.internal:${hostPolicyServer.port}/policy-test"`, + allowFailure: true, + timeoutMs: 60_000, + }); + expect(blockedGetResult.code).not.toBe(0); + expect(`${blockedGetResult.stdout}\n${blockedGetResult.stderr}`).toMatch(/403|deny/i); + + const allowedGetResult = await runCommand({ + command: OPENCLAW_OPENSHELL_COMMAND, + args: [ + "sandbox", + "create", + "--name", + allowSandboxName, + "--from", + dockerfilePath, + "--policy", + allowPolicyPath, + "--no-auto-providers", + "--no-keep", + "--", + "curl", + "--fail", + "--silent", + "--show-error", + "--max-time", + "15", + `http://host.openshell.internal:${hostPolicyServer.port}/policy-test`, + ], + env, + timeoutMs: 60_000, + }); + expect(allowedGetResult.code).toBe(0); + expect(allowedGetResult.stdout).toContain('"message":"hello-from-host"'); + } finally { + await runCommand({ + command: OPENCLAW_OPENSHELL_COMMAND, + args: ["sandbox", "delete", backend.runtimeId], + env, + allowFailure: true, + timeoutMs: 2 * 60_000, + }); + await runCommand({ + command: OPENCLAW_OPENSHELL_COMMAND, + args: ["sandbox", "delete", allowSandboxName], + env, + allowFailure: true, + timeoutMs: 2 * 60_000, + }); + await runCommand({ + command: OPENCLAW_OPENSHELL_COMMAND, + args: ["gateway", "destroy", "--name", gatewayName], + env, + allowFailure: true, + timeoutMs: 3 * 60_000, + }); + await hostPolicyServer?.close().catch(() => {}); + await fs.rm(rootDir, { recursive: true, force: true }); + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + if (previousXdgCacheHome === undefined) { + delete process.env.XDG_CACHE_HOME; + } else { + process.env.XDG_CACHE_HOME = previousXdgCacheHome; + } + } + }, + ); +}); From fa62231afca31a614d2494a4148e08b130a1601a Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:07:09 -0500 Subject: [PATCH 032/133] feishu: add structured card actions and interactive approval flows (#47873) * feishu: add structured card actions and interactive approval flows * feishu: address review fixes and test-gate regressions * feishu: hold inflight card dedup until completion * feishu: restore fire-and-forget bot menu handling * feishu: format card interaction helpers * Feishu: add changelog entry for card interactions * Feishu: add changelog entry for ACP session binding --- CHANGELOG.md | 2 + extensions/feishu/src/bot.card-action.test.ts | 344 +++++++++++++++++- extensions/feishu/src/card-action.ts | 313 +++++++++++++--- .../feishu/src/card-interaction.test.ts | 129 +++++++ extensions/feishu/src/card-interaction.ts | 168 +++++++++ extensions/feishu/src/card-ux-approval.ts | 65 ++++ .../feishu/src/card-ux-launcher.test.ts | 98 +++++ extensions/feishu/src/card-ux-launcher.ts | 120 ++++++ extensions/feishu/src/card-ux-shared.ts | 33 ++ extensions/feishu/src/monitor.account.ts | 27 +- .../feishu/src/monitor.bot-menu.test.ts | 229 ++++++++++++ src/security/audit.ts | 9 +- 12 files changed, 1485 insertions(+), 52 deletions(-) create mode 100644 extensions/feishu/src/card-interaction.test.ts create mode 100644 extensions/feishu/src/card-interaction.ts create mode 100644 extensions/feishu/src/card-ux-approval.ts create mode 100644 extensions/feishu/src/card-ux-launcher.test.ts create mode 100644 extensions/feishu/src/card-ux-launcher.ts create mode 100644 extensions/feishu/src/card-ux-shared.ts create mode 100644 extensions/feishu/src/monitor.bot-menu.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aaf84db974..ddfb252fc71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ Docs: https://docs.openclaw.ai - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. - Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only. - Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode. +- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) +- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819) ### Fixes diff --git a/extensions/feishu/src/bot.card-action.test.ts b/extensions/feishu/src/bot.card-action.test.ts index 90967b593bd..2df1ce361a1 100644 --- a/extensions/feishu/src/bot.card-action.test.ts +++ b/extensions/feishu/src/bot.card-action.test.ts @@ -1,5 +1,15 @@ -import { describe, it, expect, vi } from "vitest"; -import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + handleFeishuCardAction, + resetProcessedFeishuCardActionTokensForTests, + type FeishuCardActionEvent, +} from "./card-action.js"; +import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; +import { + FEISHU_APPROVAL_CANCEL_ACTION, + FEISHU_APPROVAL_CONFIRM_ACTION, + FEISHU_APPROVAL_REQUEST_ACTION, +} from "./card-ux-approval.js"; // Mock resolveFeishuAccount vi.mock("./accounts.js", () => ({ @@ -11,12 +21,25 @@ vi.mock("./bot.js", () => ({ handleFeishuMessage: vi.fn(), })); +const sendCardFeishuMock = vi.hoisted(() => vi.fn()); +const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); + +vi.mock("./send.js", () => ({ + sendCardFeishu: sendCardFeishuMock, + sendMessageFeishu: sendMessageFeishuMock, +})); + import { handleFeishuMessage } from "./bot.js"; describe("Feishu Card Action Handler", () => { const cfg = {} as any; // Minimal mock const runtime = { log: vi.fn(), error: vi.fn() } as any; + beforeEach(() => { + vi.clearAllMocks(); + resetProcessedFeishuCardActionTokensForTests(); + }); + it("handles card action with text payload", async () => { const event: FeishuCardActionEvent = { operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, @@ -60,4 +83,321 @@ describe("Feishu Card Action Handler", () => { }), ); }); + + it("routes quick command actions with operator and conversation context", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok3", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + sender: expect.objectContaining({ + sender_id: expect.objectContaining({ + open_id: "u123", + user_id: "uid1", + union_id: "un1", + }), + }), + message: expect.objectContaining({ + chat_id: "chat1", + content: '{"text":"/help"}', + }), + }), + }), + ); + }); + + it("opens an approval card for metadata actions", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok4", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "meta", + a: FEISHU_APPROVAL_REQUEST_ACTION, + m: { + command: "/new", + prompt: "Start a fresh session?", + }, + c: { + u: "u123", + h: "chat1", + t: "group", + s: "agent:codex:feishu:chat:chat1", + e: Date.now() + 60_000, + }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" }); + + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:chat1", + accountId: "main", + card: expect.objectContaining({ + header: expect.objectContaining({ + title: expect.objectContaining({ content: "Confirm action" }), + }), + body: expect.objectContaining({ + elements: expect.arrayContaining([ + expect.objectContaining({ + tag: "action", + actions: expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + c: expect.objectContaining({ + u: "u123", + h: "chat1", + t: "group", + s: "agent:codex:feishu:chat:chat1", + }), + }), + }), + ]), + }), + ]), + }), + }), + }), + ); + expect(handleFeishuMessage).not.toHaveBeenCalled(); + }); + + it("runs approval confirmation through the normal message path", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok5", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: FEISHU_APPROVAL_CONFIRM_ACTION, + q: "/new", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + content: '{"text":"/new"}', + }), + }), + }), + ); + }); + + it("safely rejects stale structured actions", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok6", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() - 1 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:chat1", + text: expect.stringContaining("expired"), + }), + ); + expect(handleFeishuMessage).not.toHaveBeenCalled(); + }); + + it("safely rejects wrong-user structured actions", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u999", user_id: "uid1", union_id: "un1" }, + token: "tok7", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u999", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining("different user"), + }), + ); + expect(handleFeishuMessage).not.toHaveBeenCalled(); + }); + + it("sends a lightweight cancellation notice", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok8", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "button", + a: FEISHU_APPROVAL_CANCEL_ACTION, + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:chat1", + text: "Cancelled.", + }), + ); + }); + + it("preserves p2p callbacks for DM quick actions", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok9", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "p2p-chat-1", t: "p2p", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "p2p-chat-1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + chat_id: "p2p-chat-1", + chat_type: "p2p", + }), + }), + }), + ); + }); + + it("drops duplicate structured callback tokens", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok10", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledTimes(1); + }); + + it("releases a claimed token when dispatch fails so retries can succeed", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok11", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + vi.mocked(handleFeishuMessage) + .mockRejectedValueOnce(new Error("transient")) + .mockResolvedValueOnce(undefined as never); + + await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("transient"); + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledTimes(2); + }); + + it("keeps an in-flight token claimed while a slow dispatch is still running", async () => { + vi.useFakeTimers(); + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok12", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + let resolveDispatch: (() => void) | undefined; + vi.mocked(handleFeishuMessage).mockImplementation( + () => + new Promise((resolve) => { + resolveDispatch = resolve; + }) as never, + ); + + const first = handleFeishuCardAction({ cfg, event, runtime }); + await vi.advanceTimersByTimeAsync(61_000); + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledTimes(1); + + resolveDispatch?.(); + await first; + vi.useRealTimers(); + }); }); diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index e4f76846316..d664b8d6af2 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -1,6 +1,14 @@ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; import { resolveFeishuAccount } from "./accounts.js"; import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js"; +import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js"; +import { + createApprovalCard, + FEISHU_APPROVAL_CANCEL_ACTION, + FEISHU_APPROVAL_CONFIRM_ACTION, + FEISHU_APPROVAL_REQUEST_ACTION, +} from "./card-ux-approval.js"; +import { sendCardFeishu, sendMessageFeishu } from "./send.js"; export type FeishuCardActionEvent = { operator: { @@ -20,18 +28,142 @@ export type FeishuCardActionEvent = { }; }; -function buildCardActionTextFallback(event: FeishuCardActionEvent): string { - const actionValue = event.action.value; - if (typeof actionValue === "object" && actionValue !== null) { - if ("text" in actionValue && typeof actionValue.text === "string") { - return actionValue.text; +const FEISHU_APPROVAL_CARD_TTL_MS = 5 * 60_000; +const FEISHU_CARD_ACTION_TOKEN_TTL_MS = 15 * 60_000; +const processedCardActionTokens = new Map< + string, + { status: "inflight" | "completed"; expiresAt: number } +>(); + +export function resetProcessedFeishuCardActionTokensForTests(): void { + processedCardActionTokens.clear(); +} + +function pruneProcessedCardActionTokens(now: number): void { + for (const [key, entry] of processedCardActionTokens.entries()) { + if (entry.expiresAt <= now) { + processedCardActionTokens.delete(key); } - if ("command" in actionValue && typeof actionValue.command === "string") { - return actionValue.command; - } - return JSON.stringify(actionValue); } - return String(actionValue); +} + +function beginFeishuCardActionToken(params: { + token: string; + accountId: string; + now?: number; +}): boolean { + const now = params.now ?? Date.now(); + pruneProcessedCardActionTokens(now); + const normalizedToken = params.token.trim(); + if (!normalizedToken) { + return true; + } + const key = `${params.accountId}:${normalizedToken}`; + const existing = processedCardActionTokens.get(key); + if (existing && existing.expiresAt > now) { + return false; + } + processedCardActionTokens.set(key, { + status: "inflight", + expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS, + }); + return true; +} + +function completeFeishuCardActionToken(params: { + token: string; + accountId: string; + now?: number; +}): void { + const now = params.now ?? Date.now(); + const normalizedToken = params.token.trim(); + if (!normalizedToken) { + return; + } + processedCardActionTokens.set(`${params.accountId}:${normalizedToken}`, { + status: "completed", + expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS, + }); +} + +function releaseFeishuCardActionToken(params: { token: string; accountId: string }): void { + const normalizedToken = params.token.trim(); + if (!normalizedToken) { + return; + } + processedCardActionTokens.delete(`${params.accountId}:${normalizedToken}`); +} + +function buildSyntheticMessageEvent( + event: FeishuCardActionEvent, + content: string, + chatType?: "p2p" | "group", +): FeishuMessageEvent { + return { + sender: { + sender_id: { + open_id: event.operator.open_id, + user_id: event.operator.user_id, + union_id: event.operator.union_id, + }, + }, + message: { + message_id: `card-action-${event.token}`, + chat_id: event.context.chat_id || event.operator.open_id, + chat_type: chatType ?? (event.context.chat_id ? "group" : "p2p"), + message_type: "text", + content: JSON.stringify({ text: content }), + }, + }; +} + +function resolveCallbackTarget(event: FeishuCardActionEvent): string { + const chatId = event.context.chat_id?.trim(); + if (chatId) { + return `chat:${chatId}`; + } + return `user:${event.operator.open_id}`; +} + +async function dispatchSyntheticCommand(params: { + cfg: ClawdbotConfig; + event: FeishuCardActionEvent; + command: string; + botOpenId?: string; + runtime?: RuntimeEnv; + accountId?: string; + chatType?: "p2p" | "group"; +}): Promise { + await handleFeishuMessage({ + cfg: params.cfg, + event: buildSyntheticMessageEvent(params.event, params.command, params.chatType), + botOpenId: params.botOpenId, + runtime: params.runtime, + accountId: params.accountId, + }); +} + +async function sendInvalidInteractionNotice(params: { + cfg: ClawdbotConfig; + event: FeishuCardActionEvent; + reason: "malformed" | "stale" | "wrong_user" | "wrong_conversation"; + accountId?: string; +}): Promise { + const reasonText = + params.reason === "stale" + ? "This card action has expired. Open a fresh launcher card and try again." + : params.reason === "wrong_user" + ? "This card action belongs to a different user." + : params.reason === "wrong_conversation" + ? "This card action belongs to a different conversation." + : "This card action payload is invalid."; + + await sendMessageFeishu({ + cfg: params.cfg, + to: resolveCallbackTarget(params.event), + text: `⚠️ ${reasonText}`, + accountId: params.accountId, + }); } export async function handleFeishuCardAction(params: { @@ -44,36 +176,135 @@ export async function handleFeishuCardAction(params: { const { cfg, event, runtime, accountId } = params; const account = resolveFeishuAccount({ cfg, accountId }); const log = runtime?.log ?? console.log; - const content = buildCardActionTextFallback(event); - - // Construct a synthetic message event - const messageEvent: FeishuMessageEvent = { - sender: { - sender_id: { - open_id: event.operator.open_id, - user_id: event.operator.user_id, - union_id: event.operator.union_id, - }, - }, - message: { - message_id: `card-action-${event.token}`, - chat_id: event.context.chat_id || event.operator.open_id, - chat_type: event.context.chat_id ? "group" : "p2p", - message_type: "text", - content: JSON.stringify({ text: content }), - }, - }; - - log( - `feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`, - ); - - // Dispatch as normal message - await handleFeishuMessage({ - cfg, - event: messageEvent, - botOpenId: params.botOpenId, - runtime, - accountId, + const decoded = decodeFeishuCardAction({ event }); + const claimedToken = beginFeishuCardActionToken({ + token: event.token, + accountId: account.accountId, }); + if (!claimedToken) { + log(`feishu[${account.accountId}]: skipping duplicate card action token ${event.token}`); + return; + } + + try { + if (decoded.kind === "invalid") { + log( + `feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: ${decoded.reason}`, + ); + await sendInvalidInteractionNotice({ + cfg, + event, + reason: decoded.reason, + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + if (decoded.kind === "structured") { + const { envelope } = decoded; + log( + `feishu[${account.accountId}]: handling structured card action ${envelope.a} from ${event.operator.open_id}`, + ); + + if (envelope.a === FEISHU_APPROVAL_REQUEST_ACTION) { + const command = typeof envelope.m?.command === "string" ? envelope.m.command.trim() : ""; + if (!command) { + await sendInvalidInteractionNotice({ + cfg, + event, + reason: "malformed", + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + const prompt = + typeof envelope.m?.prompt === "string" && envelope.m.prompt.trim() + ? envelope.m.prompt + : `Run \`${command}\` in this Feishu conversation?`; + await sendCardFeishu({ + cfg, + to: resolveCallbackTarget(event), + card: createApprovalCard({ + operatorOpenId: event.operator.open_id, + chatId: event.context.chat_id || undefined, + command, + prompt, + sessionKey: envelope.c?.s, + expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS, + chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"), + confirmLabel: command === "/reset" ? "Reset" : "Confirm", + }), + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + if (envelope.a === FEISHU_APPROVAL_CANCEL_ACTION) { + await sendMessageFeishu({ + cfg, + to: resolveCallbackTarget(event), + text: "Cancelled.", + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + if (envelope.a === FEISHU_APPROVAL_CONFIRM_ACTION || envelope.k === "quick") { + const command = envelope.q?.trim(); + if (!command) { + await sendInvalidInteractionNotice({ + cfg, + event, + reason: "malformed", + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + await dispatchSyntheticCommand({ + cfg, + event, + command, + botOpenId: params.botOpenId, + runtime, + accountId, + chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"), + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + await sendInvalidInteractionNotice({ + cfg, + event, + reason: "malformed", + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + const content = buildFeishuCardActionTextFallback(event); + + log( + `feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`, + ); + + await dispatchSyntheticCommand({ + cfg, + event, + command: content, + botOpenId: params.botOpenId, + runtime, + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + } catch (err) { + releaseFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + throw err; + } } diff --git a/extensions/feishu/src/card-interaction.test.ts b/extensions/feishu/src/card-interaction.test.ts new file mode 100644 index 00000000000..58aee261162 --- /dev/null +++ b/extensions/feishu/src/card-interaction.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + buildFeishuCardActionTextFallback, + createFeishuCardInteractionEnvelope, + decodeFeishuCardAction, +} from "./card-interaction.js"; + +describe("feishu card interaction decoder", () => { + it("decodes valid structured payloads", () => { + const result = decodeFeishuCardAction({ + now: 1_700_000_000_000, + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: 1_700_000_060_000 }, + }), + }, + }, + }); + + expect(result).toEqual( + expect.objectContaining({ + kind: "structured", + envelope: expect.objectContaining({ + q: "/help", + }), + }), + ); + }); + + it("falls back for legacy text-like payloads", () => { + const result = decodeFeishuCardAction({ + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { value: { text: "/ping" } }, + }, + }); + + expect(result).toEqual({ kind: "legacy", text: "/ping" }); + expect( + buildFeishuCardActionTextFallback({ + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { value: { command: "/new" } }, + }), + ).toBe("/new"); + }); + + it("rejects malformed structured payloads", () => { + const result = decodeFeishuCardAction({ + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { + value: { + oc: "ocf1", + k: "quick", + a: "broken", + m: { bad: { nested: true } }, + }, + }, + }, + }); + + expect(result).toEqual({ kind: "invalid", reason: "malformed" }); + }); + + it("rejects stale payloads", () => { + const result = decodeFeishuCardAction({ + now: 100, + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { + value: createFeishuCardInteractionEnvelope({ + k: "button", + a: "stale", + c: { e: 99, t: "group" }, + }), + }, + }, + }); + + expect(result).toEqual({ kind: "invalid", reason: "stale" }); + }); + + it("rejects wrong-conversation payloads when chat context is enforced", () => { + const result = decodeFeishuCardAction({ + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat2" }, + action: { + value: createFeishuCardInteractionEnvelope({ + k: "button", + a: "scoped", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + }, + }, + }); + + expect(result).toEqual({ kind: "invalid", reason: "wrong_conversation" }); + }); + + it("rejects malformed chat-type context", () => { + const result = decodeFeishuCardAction({ + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { + value: { + oc: "ocf1", + k: "button", + a: "bad", + c: { t: "private" }, + }, + }, + }, + }); + + expect(result).toEqual({ kind: "invalid", reason: "malformed" }); + }); +}); diff --git a/extensions/feishu/src/card-interaction.ts b/extensions/feishu/src/card-interaction.ts new file mode 100644 index 00000000000..1da2df05baf --- /dev/null +++ b/extensions/feishu/src/card-interaction.ts @@ -0,0 +1,168 @@ +export const FEISHU_CARD_INTERACTION_VERSION = "ocf1"; + +export type FeishuCardInteractionKind = "button" | "quick" | "meta"; +export type FeishuCardInteractionReason = + | "malformed" + | "stale" + | "wrong_user" + | "wrong_conversation"; + +export type FeishuCardInteractionMetadata = Record< + string, + string | number | boolean | null | undefined +>; + +export type FeishuCardInteractionEnvelope = { + oc: typeof FEISHU_CARD_INTERACTION_VERSION; + k: FeishuCardInteractionKind; + a: string; + q?: string; + m?: FeishuCardInteractionMetadata; + c?: { + u?: string; + h?: string; + s?: string; + e?: number; + t?: "p2p" | "group"; + }; +}; + +export type FeishuCardActionEventLike = { + operator: { + open_id?: string; + }; + action: { + value: unknown; + }; + context: { + chat_id?: string; + }; +}; + +export type DecodedFeishuCardAction = + | { + kind: "structured"; + envelope: FeishuCardInteractionEnvelope; + } + | { + kind: "legacy"; + text: string; + } + | { + kind: "invalid"; + reason: FeishuCardInteractionReason; + }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isInteractionKind(value: unknown): value is FeishuCardInteractionKind { + return value === "button" || value === "quick" || value === "meta"; +} + +function isMetadataValue(value: unknown): value is string | number | boolean | null | undefined { + return ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +export function createFeishuCardInteractionEnvelope( + envelope: Omit, +): FeishuCardInteractionEnvelope { + return { + oc: FEISHU_CARD_INTERACTION_VERSION, + ...envelope, + }; +} + +export function buildFeishuCardActionTextFallback(event: FeishuCardActionEventLike): string { + const actionValue = event.action.value; + if (isRecord(actionValue)) { + if (typeof actionValue.text === "string") { + return actionValue.text; + } + if (typeof actionValue.command === "string") { + return actionValue.command; + } + return JSON.stringify(actionValue); + } + return String(actionValue); +} + +export function decodeFeishuCardAction(params: { + event: FeishuCardActionEventLike; + now?: number; +}): DecodedFeishuCardAction { + const { event, now = Date.now() } = params; + const actionValue = event.action.value; + if (!isRecord(actionValue) || actionValue.oc !== FEISHU_CARD_INTERACTION_VERSION) { + return { + kind: "legacy", + text: buildFeishuCardActionTextFallback(event), + }; + } + + if (!isInteractionKind(actionValue.k) || typeof actionValue.a !== "string" || !actionValue.a) { + return { kind: "invalid", reason: "malformed" }; + } + + if (actionValue.q !== undefined && typeof actionValue.q !== "string") { + return { kind: "invalid", reason: "malformed" }; + } + + if (actionValue.m !== undefined) { + if (!isRecord(actionValue.m)) { + return { kind: "invalid", reason: "malformed" }; + } + for (const value of Object.values(actionValue.m)) { + if (!isMetadataValue(value)) { + return { kind: "invalid", reason: "malformed" }; + } + } + } + + if (actionValue.c !== undefined) { + if (!isRecord(actionValue.c)) { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.u !== undefined && typeof actionValue.c.u !== "string") { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.h !== undefined && typeof actionValue.c.h !== "string") { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.s !== undefined && typeof actionValue.c.s !== "string") { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.e !== undefined && !Number.isFinite(actionValue.c.e)) { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.t !== undefined && actionValue.c.t !== "p2p" && actionValue.c.t !== "group") { + return { kind: "invalid", reason: "malformed" }; + } + + if (typeof actionValue.c.e === "number" && actionValue.c.e < now) { + return { kind: "invalid", reason: "stale" }; + } + + const expectedUser = actionValue.c.u?.trim(); + if (expectedUser && expectedUser !== (event.operator.open_id ?? "").trim()) { + return { kind: "invalid", reason: "wrong_user" }; + } + + const expectedChat = actionValue.c.h?.trim(); + if (expectedChat && expectedChat !== (event.context.chat_id ?? "").trim()) { + return { kind: "invalid", reason: "wrong_conversation" }; + } + } + + return { + kind: "structured", + envelope: actionValue as FeishuCardInteractionEnvelope, + }; +} diff --git a/extensions/feishu/src/card-ux-approval.ts b/extensions/feishu/src/card-ux-approval.ts new file mode 100644 index 00000000000..944ace931ea --- /dev/null +++ b/extensions/feishu/src/card-ux-approval.ts @@ -0,0 +1,65 @@ +import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; +import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js"; + +export const FEISHU_APPROVAL_REQUEST_ACTION = "feishu.quick_actions.request_approval"; +export const FEISHU_APPROVAL_CONFIRM_ACTION = "feishu.approval.confirm"; +export const FEISHU_APPROVAL_CANCEL_ACTION = "feishu.approval.cancel"; + +export function createApprovalCard(params: { + operatorOpenId: string; + chatId?: string; + command: string; + prompt: string; + expiresAt: number; + chatType?: "p2p" | "group"; + sessionKey?: string; + confirmLabel?: string; + cancelLabel?: string; +}): Record { + const context = buildFeishuCardInteractionContext(params); + + return { + schema: "2.0", + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: "plain_text", + content: "Confirm action", + }, + template: "orange", + }, + body: { + elements: [ + { + tag: "markdown", + content: params.prompt, + }, + { + tag: "action", + actions: [ + buildFeishuCardButton({ + label: params.confirmLabel ?? "Confirm", + type: "primary", + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: FEISHU_APPROVAL_CONFIRM_ACTION, + q: params.command, + c: context, + }), + }), + buildFeishuCardButton({ + label: params.cancelLabel ?? "Cancel", + value: createFeishuCardInteractionEnvelope({ + k: "button", + a: FEISHU_APPROVAL_CANCEL_ACTION, + c: context, + }), + }), + ], + }, + ], + }, + }; +} diff --git a/extensions/feishu/src/card-ux-launcher.test.ts b/extensions/feishu/src/card-ux-launcher.test.ts new file mode 100644 index 00000000000..6f9f7917daf --- /dev/null +++ b/extensions/feishu/src/card-ux-launcher.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + createQuickActionLauncherCard, + isFeishuQuickActionMenuEventKey, + maybeHandleFeishuQuickActionMenu, +} from "./card-ux-launcher.js"; + +const sendCardFeishuMock = vi.hoisted(() => vi.fn()); + +vi.mock("./send.js", () => ({ + sendCardFeishu: sendCardFeishuMock, +})); + +describe("feishu quick-action launcher", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("recognizes the quick-actions bot menu key", () => { + expect(isFeishuQuickActionMenuEventKey("quick-actions")).toBe(true); + expect(isFeishuQuickActionMenuEventKey("other")).toBe(false); + }); + + it("builds a launcher card with interactive actions", () => { + const card = createQuickActionLauncherCard({ + operatorOpenId: "u123", + chatId: "chat1", + expiresAt: 123, + sessionKey: "agent:codex:feishu:chat:chat1", + }) as { + body: { + elements: Array<{ + tag: string; + actions?: Array<{ value?: { oc?: string; c?: { s?: string; t?: string } } }>; + }>; + }; + }; + + const actionBlock = card.body.elements.find((entry) => entry.tag === "action"); + expect(actionBlock?.actions).toHaveLength(3); + expect(actionBlock?.actions?.[0]?.value?.oc).toBe("ocf1"); + expect(actionBlock?.actions?.[0]?.value?.c?.s).toBe("agent:codex:feishu:chat:chat1"); + expect(actionBlock?.actions?.[0]?.value?.c?.t).toBeUndefined(); + }); + + it("opens the launcher from a supported bot menu event", async () => { + sendCardFeishuMock.mockResolvedValue({ messageId: "m1", chatId: "c1" }); + + const handled = await maybeHandleFeishuQuickActionMenu({ + cfg: {} as any, + eventKey: "quick-actions", + operatorOpenId: "u123", + accountId: "main", + now: 100, + }); + + expect(handled).toBe(true); + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "user:u123", + accountId: "main", + card: expect.objectContaining({ + body: expect.objectContaining({ + elements: expect.arrayContaining([ + expect.objectContaining({ + tag: "action", + actions: expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + c: expect.objectContaining({ + t: "p2p", + }), + }), + }), + ]), + }), + ]), + }), + }), + }), + ); + }); + + it("falls back to legacy menu handling when launcher send fails", async () => { + sendCardFeishuMock.mockRejectedValueOnce(new Error("network")); + + const handled = await maybeHandleFeishuQuickActionMenu({ + cfg: {} as any, + eventKey: "quick-actions", + operatorOpenId: "u123", + accountId: "main", + runtime: { log: vi.fn() } as any, + now: 100, + }); + + expect(handled).toBe(false); + }); +}); diff --git a/extensions/feishu/src/card-ux-launcher.ts b/extensions/feishu/src/card-ux-launcher.ts new file mode 100644 index 00000000000..3303bc2ed77 --- /dev/null +++ b/extensions/feishu/src/card-ux-launcher.ts @@ -0,0 +1,120 @@ +import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; +import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; +import { FEISHU_APPROVAL_REQUEST_ACTION } from "./card-ux-approval.js"; +import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js"; +import { sendCardFeishu } from "./send.js"; + +export const FEISHU_QUICK_ACTION_CARD_TTL_MS = 10 * 60_000; + +const QUICK_ACTION_MENU_KEYS = new Set(["quick-actions", "quick_actions", "launcher"]); + +export function isFeishuQuickActionMenuEventKey(eventKey: string): boolean { + return QUICK_ACTION_MENU_KEYS.has(eventKey.trim().toLowerCase()); +} + +export function createQuickActionLauncherCard(params: { + operatorOpenId: string; + chatId?: string; + expiresAt: number; + chatType?: "p2p" | "group"; + sessionKey?: string; +}): Record { + const context = buildFeishuCardInteractionContext(params); + return { + schema: "2.0", + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: "plain_text", + content: "Quick actions", + }, + template: "indigo", + }, + body: { + elements: [ + { + tag: "markdown", + content: "Run common actions without typing raw commands.", + }, + { + tag: "action", + actions: [ + buildFeishuCardButton({ + label: "Help", + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: context, + }), + }), + buildFeishuCardButton({ + label: "New session", + type: "primary", + value: createFeishuCardInteractionEnvelope({ + k: "meta", + a: FEISHU_APPROVAL_REQUEST_ACTION, + m: { + command: "/new", + prompt: "Start a fresh session? This will reset the current chat context.", + }, + c: context, + }), + }), + buildFeishuCardButton({ + label: "Reset", + type: "danger", + value: createFeishuCardInteractionEnvelope({ + k: "meta", + a: FEISHU_APPROVAL_REQUEST_ACTION, + m: { + command: "/reset", + prompt: "Reset this session now? Any active conversation state will be cleared.", + }, + c: context, + }), + }), + ], + }, + ], + }, + }; +} + +export async function maybeHandleFeishuQuickActionMenu(params: { + cfg: ClawdbotConfig; + eventKey: string; + operatorOpenId: string; + runtime?: RuntimeEnv; + accountId?: string; + now?: number; +}): Promise { + if (!isFeishuQuickActionMenuEventKey(params.eventKey)) { + return false; + } + + const expiresAt = (params.now ?? Date.now()) + FEISHU_QUICK_ACTION_CARD_TTL_MS; + try { + await sendCardFeishu({ + cfg: params.cfg, + to: `user:${params.operatorOpenId}`, + card: createQuickActionLauncherCard({ + operatorOpenId: params.operatorOpenId, + expiresAt, + chatType: "p2p", + }), + accountId: params.accountId, + }); + } catch (err) { + params.runtime?.log?.( + `feishu[${params.accountId ?? "default"}]: failed to open quick-action launcher for ${params.operatorOpenId}: ${String(err)}`, + ); + return false; + } + params.runtime?.log?.( + `feishu[${params.accountId ?? "default"}]: opened quick-action launcher for ${params.operatorOpenId}`, + ); + return true; +} diff --git a/extensions/feishu/src/card-ux-shared.ts b/extensions/feishu/src/card-ux-shared.ts new file mode 100644 index 00000000000..02133c39a5c --- /dev/null +++ b/extensions/feishu/src/card-ux-shared.ts @@ -0,0 +1,33 @@ +import type { FeishuCardInteractionEnvelope } from "./card-interaction.js"; + +export function buildFeishuCardButton(params: { + label: string; + value: FeishuCardInteractionEnvelope; + type?: "default" | "primary" | "danger"; +}) { + return { + tag: "button", + text: { + tag: "plain_text", + content: params.label, + }, + type: params.type ?? "default", + value: params.value, + }; +} + +export function buildFeishuCardInteractionContext(params: { + operatorOpenId: string; + chatId?: string; + expiresAt: number; + chatType?: "p2p" | "group"; + sessionKey?: string; +}) { + return { + u: params.operatorOpenId, + ...(params.chatId ? { h: params.chatId } : {}), + ...(params.sessionKey ? { s: params.sessionKey } : {}), + e: params.expiresAt, + ...(params.chatType ? { t: params.chatType } : {}), + }; +} diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index 3d761631399..241376ac0ba 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -10,6 +10,7 @@ import { type FeishuBotAddedEvent, } from "./bot.js"; import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; +import { maybeHandleFeishuQuickActionMenu } from "./card-ux-launcher.js"; import { createEventDispatcher } from "./client.js"; import { hasProcessedFeishuMessage, @@ -513,7 +514,7 @@ function registerEventHandlers( try { const event = data as { event_key?: string; - timestamp?: number; + timestamp?: string | number; operator?: { operator_name?: string; operator_id?: { open_id?: string; user_id?: string; union_id?: string }; @@ -543,14 +544,28 @@ function registerEventHandlers( }), }, }; - const promise = handleFeishuMessage({ + const handleLegacyMenu = () => + handleFeishuMessage({ + cfg, + event: syntheticEvent, + botOpenId: botOpenIds.get(accountId), + botName: botNames.get(accountId), + runtime, + chatHistories, + accountId, + }); + + const promise = maybeHandleFeishuQuickActionMenu({ cfg, - event: syntheticEvent, - botOpenId: botOpenIds.get(accountId), - botName: botNames.get(accountId), + eventKey, + operatorOpenId, runtime, - chatHistories, accountId, + }).then((handledMenu) => { + if (handledMenu) { + return; + } + return handleLegacyMenu(); }); if (fireAndForget) { promise.catch((err) => { diff --git a/extensions/feishu/src/monitor.bot-menu.test.ts b/extensions/feishu/src/monitor.bot-menu.test.ts new file mode 100644 index 00000000000..cecb0b0512c --- /dev/null +++ b/extensions/feishu/src/monitor.bot-menu.test.ts @@ -0,0 +1,229 @@ +import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { hasControlCommand } from "../../../src/auto-reply/command-detection.js"; +import { + createInboundDebouncer, + resolveInboundDebounceMs, +} from "../../../src/auto-reply/inbound-debounce.js"; +import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { monitorSingleAccount } from "./monitor.account.js"; +import { setFeishuRuntime } from "./runtime.js"; +import type { ResolvedFeishuAccount } from "./types.js"; + +const createEventDispatcherMock = vi.hoisted(() => vi.fn()); +const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {})); +const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {})); +const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async () => {})); +const sendCardFeishuMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "m1", chatId: "c1" }))); +const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() }))); + +let handlers: Record Promise> = {}; + +vi.mock("./client.js", () => ({ + createEventDispatcher: createEventDispatcherMock, +})); + +vi.mock("./monitor.transport.js", () => ({ + monitorWebSocket: monitorWebSocketMock, + monitorWebhook: monitorWebhookMock, +})); + +vi.mock("./bot.js", async () => { + const actual = await vi.importActual("./bot.js"); + return { + ...actual, + handleFeishuMessage: handleFeishuMessageMock, + }; +}); + +vi.mock("./send.js", async () => { + const actual = await vi.importActual("./send.js"); + return { + ...actual, + sendCardFeishu: sendCardFeishuMock, + }; +}); + +vi.mock("./thread-bindings.js", () => ({ + createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock, +})); + +function buildAccount(): ResolvedFeishuAccount { + return { + accountId: "default", + enabled: true, + configured: true, + appId: "cli_test", + appSecret: "secret_test", // pragma: allowlist secret + domain: "feishu", + config: { + enabled: true, + connectionMode: "websocket", + }, + } as ResolvedFeishuAccount; +} + +async function registerHandlers() { + setFeishuRuntime( + createPluginRuntimeMock({ + channel: { + debounce: { + createInboundDebouncer, + resolveInboundDebounceMs, + }, + text: { + hasControlCommand, + }, + }, + }), + ); + const register = vi.fn((registered: Record Promise>) => { + handlers = registered; + }); + createEventDispatcherMock.mockReturnValue({ register }); + + await monitorSingleAccount({ + cfg: {} as ClawdbotConfig, + account: buildAccount(), + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as RuntimeEnv, + botOpenIdSource: { + kind: "prefetched", + botOpenId: "ou_bot", + botName: "Bot", + }, + }); + + const onBotMenu = handlers["application.bot.menu_v6"]; + if (!onBotMenu) { + throw new Error("missing application.bot.menu_v6 handler"); + } + return onBotMenu; +} + +describe("Feishu bot menu handler", () => { + beforeEach(() => { + handlers = {}; + vi.clearAllMocks(); + }); + + it("opens the quick-action launcher card at the webhook/event layer", async () => { + const onBotMenu = await registerHandlers(); + + await onBotMenu({ + event_key: "quick-actions", + timestamp: "1700000000000", + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }); + + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "user:ou_user1", + card: expect.objectContaining({ + header: expect.objectContaining({ + title: expect.objectContaining({ content: "Quick actions" }), + }), + }), + }), + ); + expect(handleFeishuMessageMock).not.toHaveBeenCalled(); + }); + + it("does not block bot-menu handling on quick-action launcher send", async () => { + const onBotMenu = await registerHandlers(); + let resolveSend: (() => void) | undefined; + sendCardFeishuMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSend = () => resolve({ messageId: "m1", chatId: "c1" }); + }), + ); + + const pending = onBotMenu({ + event_key: "quick-actions", + timestamp: "1700000000000", + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }); + let settled = false; + pending.finally(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(true); + + resolveSend?.(); + await pending; + }); + + it("falls back to the legacy /menu synthetic message path for unrelated bot menu keys", async () => { + const onBotMenu = await registerHandlers(); + + await onBotMenu({ + event_key: "custom-key", + timestamp: "1700000000000", + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }); + + expect(handleFeishuMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + content: '{"text":"/menu custom-key"}', + }), + }), + }), + ); + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + }); + + it("falls back to the legacy /menu path when launcher rendering fails", async () => { + const onBotMenu = await registerHandlers(); + sendCardFeishuMock.mockRejectedValueOnce(new Error("boom")); + + await onBotMenu({ + event_key: "quick-actions", + timestamp: "1700000000000", + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }); + + await vi.waitFor(() => { + expect(handleFeishuMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + content: '{"text":"/menu quick-actions"}', + }), + }), + }), + ); + }); + }); +}); diff --git a/src/security/audit.ts b/src/security/audit.ts index b304f658d68..0b13ecc5531 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1250,13 +1250,16 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise Date: Mon, 16 Mar 2026 06:08:41 +0000 Subject: [PATCH 033/133] build: remove land gate script --- docs/ci.md | 3 --- package.json | 1 - 2 files changed, 4 deletions(-) diff --git a/docs/ci.md b/docs/ci.md index 25445d6c0ed..e8710b87cb1 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -53,7 +53,4 @@ pnpm check # types + lint + format pnpm test # vitest tests pnpm check:docs # docs format + lint + broken links pnpm release:check # validate npm pack -pnpm land:gate # maintainer land gate: frozen-lock install + check + build + test + release:check ``` - -`pnpm land:gate` intentionally includes the same frozen-lockfile install step CI uses before running `check`, `build`, `test`, and `release:check`. Use it when you want local merge-gate parity instead of piecemeal commands. diff --git a/package.json b/package.json index 124975e63d1..5aeb794f174 100644 --- a/package.json +++ b/package.json @@ -270,7 +270,6 @@ "ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'", "ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'", "ios:run": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'", - "land:gate": "pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true && pnpm check && pnpm build && pnpm test && pnpm release:check", "lint": "oxlint --type-aware", "lint:agent:ingress-owner": "node scripts/check-ingress-agent-owner-context.mjs", "lint:all": "pnpm lint && pnpm lint:swift", From ed82c7e57be116ad49738448c0b2d39929ddbecb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:11:36 -0700 Subject: [PATCH 034/133] Status: lazy-load tailscale and memory scan deps --- src/commands/status.scan.deps.runtime.ts | 2 ++ src/commands/status.scan.test.ts | 7 ++---- src/commands/status.scan.ts | 31 ++++++++++++++++++------ 3 files changed, 27 insertions(+), 13 deletions(-) create mode 100644 src/commands/status.scan.deps.runtime.ts diff --git a/src/commands/status.scan.deps.runtime.ts b/src/commands/status.scan.deps.runtime.ts new file mode 100644 index 00000000000..b9838d2176f --- /dev/null +++ b/src/commands/status.scan.deps.runtime.ts @@ -0,0 +1,2 @@ +export { getTailnetHostname } from "../infra/tailscale.js"; +export { getMemorySearchManager } from "../memory/index.js"; diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 7dccbefb621..6e778070c09 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -51,8 +51,9 @@ vi.mock("../infra/os-summary.js", () => ({ resolveOsSummary: vi.fn(() => ({ label: "test-os" })), })); -vi.mock("../infra/tailscale.js", () => ({ +vi.mock("./status.scan.deps.runtime.js", () => ({ getTailnetHostname: vi.fn(), + getMemorySearchManager: vi.fn(), })); vi.mock("../gateway/call.js", () => ({ @@ -69,10 +70,6 @@ vi.mock("./status.gateway-probe.js", () => ({ resolveGatewayProbeAuthResolution: mocks.resolveGatewayProbeAuthResolution, })); -vi.mock("../memory/index.js", () => ({ - getMemorySearchManager: vi.fn(), -})); - vi.mock("../process/exec.js", () => ({ runExec: vi.fn(), })); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index eaa9ad3066c..bbe10301624 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -8,8 +8,6 @@ import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { probeGateway } from "../gateway/probe.js"; import { resolveOsSummary } from "../infra/os-summary.js"; -import { getTailnetHostname } from "../infra/tailscale.js"; -import { getMemorySearchManager } from "../memory/index.js"; import type { MemoryProviderStatus } from "../memory/types.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -51,6 +49,9 @@ type GatewayProbeSnapshot = { let pluginRegistryModulePromise: Promise | undefined; let statusScanRuntimeModulePromise: Promise | undefined; +let statusScanDepsRuntimeModulePromise: + | Promise + | undefined; function loadPluginRegistryModule() { pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); @@ -62,6 +63,11 @@ function loadStatusScanRuntimeModule() { return statusScanRuntimeModulePromise; } +function loadStatusScanDepsRuntimeModule() { + statusScanDepsRuntimeModulePromise ??= import("./status.scan.deps.runtime.js"); + return statusScanDepsRuntimeModulePromise; +} + function deferResult(promise: Promise): Promise> { return promise.then( (value) => ({ ok: true, value }), @@ -184,6 +190,7 @@ async function resolveMemoryStatusSnapshot(params: { return null; } const agentId = agentStatus.defaultId ?? "main"; + const { getMemorySearchManager } = await loadStatusScanDepsRuntimeModule(); const { manager } = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); if (!manager) { return null; @@ -226,9 +233,13 @@ async function scanStatusJsonFast(opts: { const tailscaleDnsPromise = tailscaleMode === "off" ? Promise.resolve(null) - : getTailnetHostname((cmd, args) => - runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), - ).catch(() => null); + : loadStatusScanDepsRuntimeModule() + .then(({ getTailnetHostname }) => + getTailnetHostname((cmd, args) => + runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), + ), + ) + .catch(() => null); const gatewayProbePromise = resolveGatewayProbeSnapshot({ cfg, opts }); @@ -318,9 +329,13 @@ export async function scanStatus( const tailscaleDnsPromise = tailscaleMode === "off" ? Promise.resolve(null) - : getTailnetHostname((cmd, args) => - runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), - ).catch(() => null); + : loadStatusScanDepsRuntimeModule() + .then(({ getTailnetHostname }) => + getTailnetHostname((cmd, args) => + runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), + ), + ) + .catch(() => null); const updateTimeoutMs = opts.all ? 6500 : 2500; const updatePromise = deferResult( getUpdateCheckResult({ From 81d3c6c9099dafa2c7d3e7dbd922c91175bb787a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:11:09 -0700 Subject: [PATCH 035/133] Tests: fix Feishu full registration mock --- extensions/feishu/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/feishu/index.test.ts b/extensions/feishu/index.test.ts index 5236e4bb542..90de46ff6ab 100644 --- a/extensions/feishu/index.test.ts +++ b/extensions/feishu/index.test.ts @@ -51,6 +51,7 @@ describe("feishu plugin register", () => { registerChannel, on: vi.fn(), config: {}, + registrationMode: "full", } as unknown as OpenClawPluginApi; plugin.register(api); From 853d8c0d8e5bdd4028a05eeea32778a6fe9c743e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:17:34 -0700 Subject: [PATCH 036/133] Tests: cover plugin capability matrix --- extensions/discord/src/channel.ts | 2 + extensions/telegram/src/channel.ts | 2 + .../plugins/message-capability-matrix.test.ts | 175 ++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 src/channels/plugins/message-capability-matrix.test.ts diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 1b0e003202c..82532f4c43b 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -74,6 +74,8 @@ function formatDiscordIntents(intents?: { const discordMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [], + getCapabilities: (ctx) => + getDiscordRuntime().channel.discord.messageActions?.getCapabilities?.(ctx) ?? [], extractToolSend: (ctx) => getDiscordRuntime().channel.discord.messageActions?.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 2aebfe5652c..cbdb146b608 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -179,6 +179,8 @@ function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean { const telegramMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [], + getCapabilities: (ctx) => + getTelegramRuntime().channel.telegram.messageActions?.getCapabilities?.(ctx) ?? [], extractToolSend: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { diff --git a/src/channels/plugins/message-capability-matrix.test.ts b/src/channels/plugins/message-capability-matrix.test.ts new file mode 100644 index 00000000000..b8d289aa56b --- /dev/null +++ b/src/channels/plugins/message-capability-matrix.test.ts @@ -0,0 +1,175 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +const telegramGetCapabilitiesMock = vi.fn(); +const discordGetCapabilitiesMock = vi.fn(); + +vi.mock("../../../extensions/telegram/src/runtime.js", () => ({ + getTelegramRuntime: () => ({ + channel: { + telegram: { + messageActions: { + getCapabilities: telegramGetCapabilitiesMock, + }, + }, + }, + }), +})); + +vi.mock("../../../extensions/discord/src/runtime.js", () => ({ + getDiscordRuntime: () => ({ + channel: { + discord: { + messageActions: { + getCapabilities: discordGetCapabilitiesMock, + }, + }, + }, + }), +})); + +const { slackPlugin } = await import("../../../extensions/slack/src/channel.js"); +const { telegramPlugin } = await import("../../../extensions/telegram/src/channel.js"); +const { discordPlugin } = await import("../../../extensions/discord/src/channel.js"); +const { mattermostPlugin } = await import("../../../extensions/mattermost/src/channel.js"); +const { feishuPlugin } = await import("../../../extensions/feishu/src/channel.js"); +const { msteamsPlugin } = await import("../../../extensions/msteams/src/channel.js"); +const { zaloPlugin } = await import("../../../extensions/zalo/src/channel.js"); + +describe("channel action capability matrix", () => { + afterEach(() => { + telegramGetCapabilitiesMock.mockReset(); + discordGetCapabilitiesMock.mockReset(); + }); + + it("exposes Slack blocks by default and interactive when enabled", () => { + const baseCfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, + } as OpenClawConfig; + const interactiveCfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + capabilities: { interactiveReplies: true }, + }, + }, + } as OpenClawConfig; + + expect(slackPlugin.actions?.getCapabilities?.({ cfg: baseCfg })).toEqual(["blocks"]); + expect(slackPlugin.actions?.getCapabilities?.({ cfg: interactiveCfg })).toEqual([ + "blocks", + "interactive", + ]); + }); + + it("forwards Telegram action capabilities through the channel wrapper", () => { + telegramGetCapabilitiesMock.mockReturnValue(["interactive", "buttons"]); + + const result = telegramPlugin.actions?.getCapabilities?.({ cfg: {} as OpenClawConfig }); + + expect(result).toEqual(["interactive", "buttons"]); + expect(telegramGetCapabilitiesMock).toHaveBeenCalledWith({ cfg: {} }); + }); + + it("forwards Discord action capabilities through the channel wrapper", () => { + discordGetCapabilitiesMock.mockReturnValue(["interactive", "components"]); + + const result = discordPlugin.actions?.getCapabilities?.({ cfg: {} as OpenClawConfig }); + + expect(result).toEqual(["interactive", "components"]); + expect(discordGetCapabilitiesMock).toHaveBeenCalledWith({ cfg: {} }); + }); + + it("exposes Mattermost buttons only when an account is configured", () => { + const configuredCfg = { + channels: { + mattermost: { + enabled: true, + botToken: "mm-token", + baseUrl: "https://chat.example.com", + }, + }, + } as OpenClawConfig; + const unconfiguredCfg = { + channels: { + mattermost: { + enabled: true, + }, + }, + } as OpenClawConfig; + + expect(mattermostPlugin.actions?.getCapabilities?.({ cfg: configuredCfg })).toEqual([ + "buttons", + ]); + expect(mattermostPlugin.actions?.getCapabilities?.({ cfg: unconfiguredCfg })).toEqual([]); + }); + + it("exposes Feishu cards only when credentials are configured", () => { + const configuredCfg = { + channels: { + feishu: { + enabled: true, + appId: "cli_a", + appSecret: "secret", + }, + }, + } as OpenClawConfig; + const disabledCfg = { + channels: { + feishu: { + enabled: false, + appId: "cli_a", + appSecret: "secret", + }, + }, + } as OpenClawConfig; + + expect(feishuPlugin.actions?.getCapabilities?.({ cfg: configuredCfg })).toEqual(["cards"]); + expect(feishuPlugin.actions?.getCapabilities?.({ cfg: disabledCfg })).toEqual([]); + }); + + it("exposes MSTeams cards only when credentials are configured", () => { + const configuredCfg = { + channels: { + msteams: { + enabled: true, + tenantId: "tenant", + appId: "app", + appPassword: "secret", + }, + }, + } as OpenClawConfig; + const disabledCfg = { + channels: { + msteams: { + enabled: false, + tenantId: "tenant", + appId: "app", + appPassword: "secret", + }, + }, + } as OpenClawConfig; + + expect(msteamsPlugin.actions?.getCapabilities?.({ cfg: configuredCfg })).toEqual(["cards"]); + expect(msteamsPlugin.actions?.getCapabilities?.({ cfg: disabledCfg })).toEqual([]); + }); + + it("keeps Zalo actions on the empty capability set", () => { + const cfg = { + channels: { + zalo: { + enabled: true, + botToken: "zl-token", + }, + }, + } as OpenClawConfig; + + expect(zaloPlugin.actions?.getCapabilities?.({ cfg })).toEqual([]); + }); +}); From 7b2a7da5493b1576b5ff588d11007dda0bb6fa59 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:17:59 -0700 Subject: [PATCH 037/133] Gateway: import normalizeAgentId in hooks --- src/gateway/hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index d9e23060f04..c11595f8f18 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -5,7 +5,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js"; -import { parseAgentSessionKey } from "../routing/session-key.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js"; import { resolveAllowedAgentIds } from "./hooks-policy.js"; From 130b575c21b7c26a360a7815049640b4c36f3a1d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 06:21:40 +0000 Subject: [PATCH 038/133] fix: recover bonjour advertiser from ciao announce loops --- src/infra/bonjour.test.ts | 54 ++++++++- src/infra/bonjour.ts | 245 ++++++++++++++++++++++++++------------ 2 files changed, 221 insertions(+), 78 deletions(-) diff --git a/src/infra/bonjour.test.ts b/src/infra/bonjour.test.ts index d8f976fdc41..efccc1ce8b1 100644 --- a/src/infra/bonjour.test.ts +++ b/src/infra/bonjour.test.ts @@ -27,21 +27,32 @@ function mockCiaoService(params?: { advertise?: ReturnType; destroy?: ReturnType; serviceState?: string; + stateRef?: { value: string }; on?: ReturnType; }) { const advertise = params?.advertise ?? vi.fn().mockResolvedValue(undefined); const destroy = params?.destroy ?? vi.fn().mockResolvedValue(undefined); const on = params?.on ?? vi.fn(); createService.mockImplementation((options: Record) => { - return { + const service = { advertise, destroy, - serviceState: params?.serviceState ?? "announced", on, getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, getHostname: () => asString(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; + Object.defineProperty(service, "serviceState", { + configurable: true, + enumerable: true, + get: () => params?.stateRef?.value ?? params?.serviceState ?? "announced", + set: (value: string) => { + if (params?.stateRef) { + params.stateRef.value = value; + } + }, + }); + return service; }); return { advertise, destroy, on }; } @@ -254,7 +265,7 @@ describe("gateway bonjour advertiser", () => { expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("advertise failed")); // watchdog should attempt re-advertise at the 60s interval tick - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(15_000); expect(advertise).toHaveBeenCalledTimes(2); await started.stop(); @@ -283,6 +294,43 @@ describe("gateway bonjour advertiser", () => { await started.stop(); }); + it("recreates the advertiser when ciao gets stuck announcing", async () => { + enableAdvertiserUnitMode(); + vi.useFakeTimers(); + + const stateRef = { value: "announcing" }; + const destroy = vi.fn().mockResolvedValue(undefined); + const advertise = vi.fn().mockImplementation(() => { + if (advertise.mock.calls.length === 1) { + stateRef.value = "announcing"; + return new Promise(() => {}); + } + stateRef.value = "announced"; + return Promise.resolve(); + }); + mockCiaoService({ advertise, destroy, stateRef }); + + const started = await startGatewayBonjourAdvertiser({ + gatewayPort: 18789, + sshPort: 2222, + }); + + expect(createService).toHaveBeenCalledTimes(1); + expect(advertise).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(15_000); + + expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("restarting advertiser")); + expect(createService).toHaveBeenCalledTimes(2); + expect(advertise).toHaveBeenCalledTimes(2); + expect(destroy).toHaveBeenCalledTimes(1); + expect(shutdown).toHaveBeenCalledTimes(1); + + await started.stop(); + expect(destroy).toHaveBeenCalledTimes(2); + expect(shutdown).toHaveBeenCalledTimes(2); + }); + it("normalizes hostnames with domains for service names", async () => { // Allow advertiser to run in unit tests. delete process.env.VITEST; diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index 7d405741a0e..9e7790e2065 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -58,6 +58,32 @@ type BonjourService = { serviceState: string; }; +type BonjourCycle = { + responder: { + createService: (options: { + name: string; + type: string; + protocol: unknown; + port: number; + domain: string; + hostname: string; + txt: Record; + }) => unknown; + shutdown: () => Promise; + }; + services: Array<{ label: string; svc: BonjourService }>; + cleanupUnhandledRejection?: () => void; +}; + +type ServiceStateTracker = { + state: string; + sinceMs: number; +}; + +const WATCHDOG_INTERVAL_MS = 5_000; +const REPAIR_DEBOUNCE_MS = 30_000; +const STUCK_ANNOUNCING_MS = 8_000; + function serviceSummary(label: string, svc: BonjourService): string { let fqdn = "unknown"; let hostname = "unknown"; @@ -89,7 +115,6 @@ export async function startGatewayBonjourAdvertiser( } const { getResponder, Protocol } = await import("@homebridge/ciao"); - const responder = getResponder(); // mDNS service instance names are single DNS labels; dots in hostnames (like // `Mac.localdomain`) can confuse some resolvers/browsers and break discovery. @@ -133,8 +158,6 @@ export async function startGatewayBonjourAdvertiser( txtBase.cliPath = opts.cliPath.trim(); } - const services: Array<{ label: string; svc: BonjourService }> = []; - // Build TXT record for the gateway service. // In minimal mode, omit sshPort to avoid advertising SSH availability. const gatewayTxt: Record = { @@ -145,25 +168,91 @@ export async function startGatewayBonjourAdvertiser( gatewayTxt.sshPort = String(opts.sshPort ?? 22); } - const gateway = responder.createService({ - name: safeServiceName(instanceName), - type: "openclaw-gw", - protocol: Protocol.TCP, - port: opts.gatewayPort, - domain: "local", - hostname, - txt: gatewayTxt, - }); - services.push({ - label: "gateway", - svc: gateway as unknown as BonjourService, - }); + function createCycle(): BonjourCycle { + const responder = getResponder(); + const services: Array<{ label: string; svc: BonjourService }> = []; - let ciaoCancellationRejectionHandler: (() => void) | undefined; - if (services.length > 0) { - ciaoCancellationRejectionHandler = registerUnhandledRejectionHandler( - ignoreCiaoCancellationRejection, - ); + const gateway = responder.createService({ + name: safeServiceName(instanceName), + type: "openclaw-gw", + protocol: Protocol.TCP, + port: opts.gatewayPort, + domain: "local", + hostname, + txt: gatewayTxt, + }); + services.push({ + label: "gateway", + svc: gateway as unknown as BonjourService, + }); + + const cleanupUnhandledRejection = + services.length > 0 + ? registerUnhandledRejectionHandler(ignoreCiaoCancellationRejection) + : undefined; + + return { responder, services, cleanupUnhandledRejection }; + } + + async function stopCycle(cycle: BonjourCycle | null) { + if (!cycle) { + return; + } + for (const { svc } of cycle.services) { + try { + await svc.destroy(); + } catch { + /* ignore */ + } + } + try { + await cycle.responder.shutdown(); + } catch { + /* ignore */ + } finally { + cycle.cleanupUnhandledRejection?.(); + } + } + + function attachConflictListeners(services: Array<{ label: string; svc: BonjourService }>) { + for (const { label, svc } of services) { + try { + svc.on("name-change", (name: unknown) => { + const next = typeof name === "string" ? name : String(name); + logWarn(`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`); + }); + svc.on("hostname-change", (nextHostname: unknown) => { + const next = typeof nextHostname === "string" ? nextHostname : String(nextHostname); + logWarn( + `bonjour: ${label} hostname conflict resolved; newHostname=${JSON.stringify(next)}`, + ); + }); + } catch (err) { + logDebug(`bonjour: failed to attach listeners for ${label}: ${String(err)}`); + } + } + } + + function startAdvertising(services: Array<{ label: string; svc: BonjourService }>) { + for (const { label, svc } of services) { + try { + void svc + .advertise() + .then(() => { + // Keep this out of stdout/stderr (menubar + tests) but capture in the rolling log. + getLogger().info(`bonjour: advertised ${serviceSummary(label, svc)}`); + }) + .catch((err) => { + logWarn( + `bonjour: advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`, + ); + }); + } catch (err) { + logWarn( + `bonjour: advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`, + ); + } + } } logDebug( @@ -172,55 +261,72 @@ export async function startGatewayBonjourAdvertiser( )}, gatewayPort=${opts.gatewayPort}${opts.minimal ? ", minimal=true" : `, sshPort=${opts.sshPort ?? 22}`})`, ); - for (const { label, svc } of services) { - try { - svc.on("name-change", (name: unknown) => { - const next = typeof name === "string" ? name : String(name); - logWarn(`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`); - }); - svc.on("hostname-change", (nextHostname: unknown) => { - const next = typeof nextHostname === "string" ? nextHostname : String(nextHostname); - logWarn( - `bonjour: ${label} hostname conflict resolved; newHostname=${JSON.stringify(next)}`, - ); - }); - } catch (err) { - logDebug(`bonjour: failed to attach listeners for ${label}: ${String(err)}`); - } - } + let stopped = false; + let recreatePromise: Promise | null = null; + let cycle = createCycle(); + const stateTracker = new Map(); + attachConflictListeners(cycle.services); + startAdvertising(cycle.services); - // Do not block gateway startup on mDNS probing/announce. Advertising can take - // multiple seconds depending on network state; the gateway should come up even - // if Bonjour is slow or fails. - for (const { label, svc } of services) { - try { - void svc - .advertise() - .then(() => { - // Keep this out of stdout/stderr (menubar + tests) but capture in the rolling log. - getLogger().info(`bonjour: advertised ${serviceSummary(label, svc)}`); - }) - .catch((err) => { - logWarn( - `bonjour: advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`, - ); - }); - } catch (err) { - logWarn( - `bonjour: advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`, - ); + const updateStateTrackers = (services: Array<{ label: string; svc: BonjourService }>) => { + const now = Date.now(); + for (const { label, svc } of services) { + const nextState = typeof svc.serviceState === "string" ? svc.serviceState : "unknown"; + const current = stateTracker.get(label); + if (!current || current.state !== nextState) { + stateTracker.set(label, { state: nextState, sinceMs: now }); + } } - } + }; + + const recreateAdvertiser = async (reason: string) => { + if (stopped) { + return; + } + if (recreatePromise) { + return recreatePromise; + } + recreatePromise = (async () => { + logWarn(`bonjour: restarting advertiser (${reason})`); + const previous = cycle; + cycle = createCycle(); + stateTracker.clear(); + attachConflictListeners(cycle.services); + startAdvertising(cycle.services); + await stopCycle(previous); + })().finally(() => { + recreatePromise = null; + }); + return recreatePromise; + }; // Watchdog: if we ever end up in an unannounced state (e.g. after sleep/wake or // interface churn), try to re-advertise instead of requiring a full gateway restart. const lastRepairAttempt = new Map(); const watchdog = setInterval(() => { - for (const { label, svc } of services) { + if (stopped || recreatePromise) { + return; + } + updateStateTrackers(cycle.services); + for (const { label, svc } of cycle.services) { const stateUnknown = (svc as { serviceState?: unknown }).serviceState; if (typeof stateUnknown !== "string") { continue; } + const tracked = stateTracker.get(label); + if ( + stateUnknown === "announcing" && + tracked && + Date.now() - tracked.sinceMs >= STUCK_ANNOUNCING_MS + ) { + void recreateAdvertiser( + `service stuck announcing for ${Date.now() - tracked.sinceMs}ms (${serviceSummary( + label, + svc, + )})`, + ); + return; + } if (stateUnknown === "announced" || stateUnknown === "announcing") { continue; } @@ -233,7 +339,7 @@ export async function startGatewayBonjourAdvertiser( } const now = Date.now(); const last = lastRepairAttempt.get(key) ?? 0; - if (now - last < 30_000) { + if (now - last < REPAIR_DEBOUNCE_MS) { continue; } lastRepairAttempt.set(key, now); @@ -256,26 +362,15 @@ export async function startGatewayBonjourAdvertiser( ); } } - }, 60_000); + }, WATCHDOG_INTERVAL_MS); watchdog.unref?.(); return { stop: async () => { + stopped = true; clearInterval(watchdog); - for (const { svc } of services) { - try { - await svc.destroy(); - } catch { - /* ignore */ - } - } - try { - await responder.shutdown(); - } catch { - /* ignore */ - } finally { - ciaoCancellationRejectionHandler?.(); - } + await recreatePromise; + await stopCycle(cycle); }, }; } From 4ab016a9bde81100aff88969c05e26635e60082c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 06:22:02 +0000 Subject: [PATCH 039/133] fix: preserve loopback gateway scopes for local auth --- src/gateway/call.test.ts | 4 +- src/gateway/call.ts | 15 +++---- src/gateway/probe.auth.integration.test.ts | 50 ++++++++++++++++++++++ src/gateway/probe.test.ts | 11 ++++- src/gateway/probe.ts | 5 ++- 5 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 src/gateway/probe.auth.integration.test.ts diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 7fd9b7c84cb..5ffd3ce3c51 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -209,7 +209,7 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.token).toBe("explicit-token"); }); - it("does not attach device identity for local loopback shared-token auth", async () => { + it("keeps device identity enabled for local loopback shared-token auth", async () => { setLocalLoopbackGatewayConfig(); await callGateway({ @@ -219,7 +219,7 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); expect(lastClientOptions?.token).toBe("explicit-token"); - expect(lastClientOptions?.deviceIdentity).toBeUndefined(); + expect(lastClientOptions?.deviceIdentity).toBeDefined(); }); it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 300391b6047..98793dd4071 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -86,15 +86,12 @@ function shouldAttachDeviceIdentityForGatewayCall(params: { token?: string; password?: string; }): boolean { - if (!(params.token || params.password)) { - return true; - } - try { - const parsed = new URL(params.url); - return !["127.0.0.1", "::1", "localhost"].includes(parsed.hostname); - } catch { - return true; - } + void params; + // Shared-auth local calls used to skip device identity as an optimization, but + // device-less operator connects now have their self-declared scopes stripped. + // Keep identity enabled so local authenticated calls stay device-bound and + // retain their least-privilege scopes. + return true; } export type ExplicitGatewayAuth = { diff --git a/src/gateway/probe.auth.integration.test.ts b/src/gateway/probe.auth.integration.test.ts new file mode 100644 index 00000000000..eed4d1be8ac --- /dev/null +++ b/src/gateway/probe.auth.integration.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { installGatewayTestHooks, testState, withGatewayServer } from "./test-helpers.js"; + +installGatewayTestHooks(); + +const { callGateway } = await import("./call.js"); +const { probeGateway } = await import("./probe.js"); + +describe("probeGateway auth integration", () => { + it("keeps direct local authenticated status RPCs device-bound", async () => { + const token = + typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" + ? ((testState.gatewayAuth as { token?: string }).token ?? "") + : ""; + expect(token).toBeTruthy(); + + await withGatewayServer(async ({ port }) => { + const status = await callGateway({ + url: `ws://127.0.0.1:${port}`, + token, + method: "status", + timeoutMs: 5_000, + }); + + expect(status).toBeTruthy(); + }); + }); + + it("keeps detail RPCs available for local authenticated probes", async () => { + const token = + typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" + ? ((testState.gatewayAuth as { token?: string }).token ?? "") + : ""; + expect(token).toBeTruthy(); + + await withGatewayServer(async ({ port }) => { + const result = await probeGateway({ + url: `ws://127.0.0.1:${port}`, + auth: { token }, + timeoutMs: 5_000, + }); + + expect(result.ok).toBe(true); + expect(result.error).toBeNull(); + expect(result.health).not.toBeNull(); + expect(result.status).not.toBeNull(); + expect(result.configSnapshot).not.toBeNull(); + }); + }); +}); diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index f91dc5148d5..4a2374e17cb 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -51,7 +51,7 @@ describe("probeGateway", () => { }); expect(gatewayClientState.options?.scopes).toEqual(["operator.read"]); - expect(gatewayClientState.options?.deviceIdentity).toBeNull(); + expect(gatewayClientState.options?.deviceIdentity).toBeUndefined(); expect(gatewayClientState.requests).toEqual([ "health", "status", @@ -71,6 +71,15 @@ describe("probeGateway", () => { expect(gatewayClientState.options?.deviceIdentity).toBeUndefined(); }); + it("keeps device identity disabled for unauthenticated loopback probes", async () => { + await probeGateway({ + url: "ws://127.0.0.1:18789", + timeoutMs: 1_000, + }); + + expect(gatewayClientState.options?.deviceIdentity).toBeNull(); + }); + it("skips detail RPCs for lightweight reachability probes", async () => { const result = await probeGateway({ url: "ws://127.0.0.1:18789", diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 87a77b8bfef..bbd36639b78 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -44,7 +44,10 @@ export async function probeGateway(opts: { const disableDeviceIdentity = (() => { try { - return isLoopbackHost(new URL(opts.url).hostname); + const hostname = new URL(opts.url).hostname; + // Local authenticated probes should stay device-bound so read/detail RPCs + // are not scope-limited by the shared-auth scope stripping hardening. + return isLoopbackHost(hostname) && !(opts.auth?.token || opts.auth?.password); } catch { return false; } From a608d0955286640a6c15aa93c0cb5bd0e18c9529 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:23:50 -0700 Subject: [PATCH 040/133] Status: lazy-load summary session helpers --- src/commands/status.summary.runtime.ts | 2 ++ src/commands/status.summary.ts | 12 ++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 src/commands/status.summary.runtime.ts diff --git a/src/commands/status.summary.runtime.ts b/src/commands/status.summary.runtime.ts new file mode 100644 index 00000000000..df1ae881d4f --- /dev/null +++ b/src/commands/status.summary.runtime.ts @@ -0,0 +1,2 @@ +export { resolveContextTokensForModel } from "../agents/context.js"; +export { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js"; diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 3d151c64772..bc2c7b4c205 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -1,4 +1,3 @@ -import { resolveContextTokensForModel } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; @@ -12,7 +11,6 @@ import { type SessionEntry, } from "../config/sessions.js"; import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; -import { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; @@ -21,6 +19,9 @@ import type { HeartbeatStatus, SessionStatus, StatusSummary } from "./status.typ let channelSummaryModulePromise: Promise | undefined; let linkChannelModulePromise: Promise | undefined; +let statusSummaryRuntimeModulePromise: + | Promise + | undefined; function loadChannelSummaryModule() { channelSummaryModulePromise ??= import("../infra/channel-summary.js"); @@ -32,6 +33,11 @@ function loadLinkChannelModule() { return linkChannelModulePromise; } +function loadStatusSummaryRuntimeModule() { + statusSummaryRuntimeModulePromise ??= import("./status.summary.runtime.js"); + return statusSummaryRuntimeModulePromise; +} + const buildFlags = (entry?: SessionEntry): string[] => { if (!entry) { return []; @@ -97,6 +103,8 @@ export async function getStatusSummary( } = {}, ): Promise { const { includeSensitive = true } = options; + const { classifySessionKey, resolveContextTokensForModel, resolveSessionModelRef } = + await loadStatusSummaryRuntimeModule(); const cfg = options.config ?? loadConfig(); const needsChannelPlugins = hasPotentialConfiguredChannels(cfg); const linkContext = needsChannelPlugins From d5b12f505cbf3ffe4aeb8a85209ec46c9d7e22e4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:24:03 -0700 Subject: [PATCH 041/133] Status: lazy-load security audit commands --- src/commands/status-json.ts | 23 +++++++++++++++-------- src/commands/status.command.ts | 26 ++++++++++++++------------ src/security/audit.runtime.ts | 1 + 3 files changed, 30 insertions(+), 20 deletions(-) create mode 100644 src/security/audit.runtime.ts diff --git a/src/commands/status-json.ts b/src/commands/status-json.ts index 035f2c71245..2579717679c 100644 --- a/src/commands/status-json.ts +++ b/src/commands/status-json.ts @@ -2,17 +2,22 @@ import { callGateway } from "../gateway/call.js"; import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; import type { RuntimeEnv } from "../runtime.js"; -import { runSecurityAudit } from "../security/audit.js"; import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; import { scanStatus } from "./status.scan.js"; let providerUsagePromise: Promise | undefined; +let securityAuditModulePromise: Promise | undefined; function loadProviderUsage() { providerUsagePromise ??= import("../infra/provider-usage.js"); return providerUsagePromise; } +function loadSecurityAuditModule() { + securityAuditModulePromise ??= import("../security/audit.runtime.js"); + return securityAuditModulePromise; +} + export async function statusJsonCommand( opts: { deep?: boolean; @@ -23,13 +28,15 @@ export async function statusJsonCommand( runtime: RuntimeEnv, ) { const scan = await scanStatus({ json: true, timeoutMs: opts.timeoutMs, all: opts.all }, runtime); - const securityAudit = await runSecurityAudit({ - config: scan.cfg, - sourceConfig: scan.sourceConfig, - deep: false, - includeFilesystem: true, - includeChannelSecurity: true, - }); + const securityAudit = await loadSecurityAuditModule().then(({ runSecurityAudit }) => + runSecurityAudit({ + config: scan.cfg, + sourceConfig: scan.sourceConfig, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }), + ); const usage = opts.usage ? await loadProviderUsage().then(({ loadProviderUsageSummary }) => diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 92702bac66e..9f17b1a9fee 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -14,7 +14,6 @@ import { type Tone, } from "../memory/status-format.js"; import type { RuntimeEnv } from "../runtime.js"; -import { runSecurityAudit } from "../security/audit.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { formatHealthChannelLines, type HealthSummary } from "./health.js"; @@ -37,12 +36,18 @@ import { } from "./status.update.js"; let providerUsagePromise: Promise | undefined; +let securityAuditModulePromise: Promise | undefined; function loadProviderUsage() { providerUsagePromise ??= import("../infra/provider-usage.js"); return providerUsagePromise; } +function loadSecurityAuditModule() { + securityAuditModulePromise ??= import("../security/audit.runtime.js"); + return securityAuditModulePromise; +} + function resolvePairingRecoveryContext(params: { error?: string | null; closeReason?: string | null; @@ -90,28 +95,25 @@ export async function statusCommand( { json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all }, runtime, ); - const securityAudit = opts.json - ? await runSecurityAudit({ + const runSecurityAudit = async () => + await loadSecurityAuditModule().then(({ runSecurityAudit }) => + runSecurityAudit({ config: scan.cfg, sourceConfig: scan.sourceConfig, deep: false, includeFilesystem: true, includeChannelSecurity: true, - }) + }), + ); + const securityAudit = opts.json + ? await runSecurityAudit() : await withProgress( { label: "Running security audit…", indeterminate: true, enabled: true, }, - async () => - await runSecurityAudit({ - config: scan.cfg, - sourceConfig: scan.sourceConfig, - deep: false, - includeFilesystem: true, - includeChannelSecurity: true, - }), + async () => await runSecurityAudit(), ); const { cfg, diff --git a/src/security/audit.runtime.ts b/src/security/audit.runtime.ts new file mode 100644 index 00000000000..349d2f26fe5 --- /dev/null +++ b/src/security/audit.runtime.ts @@ -0,0 +1 @@ +export { runSecurityAudit } from "./audit.js"; From d163278e9cdfafcf0fd28be9c7d819a965695db9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 23:24:18 -0700 Subject: [PATCH 042/133] refactor: move channel delivery and ACP seams into plugins --- extensions/discord/src/channel.ts | 104 ++++++ extensions/feishu/src/channel.ts | 78 +++++ extensions/imessage/src/channel.ts | 18 ++ extensions/signal/src/channel.ts | 185 ++++++++++- extensions/slack/src/channel.ts | 57 ++++ extensions/telegram/src/channel.ts | 92 ++++++ extensions/telegram/src/outbound-adapter.ts | 3 + extensions/whatsapp/src/channel.ts | 62 +++- src/acp/persistent-bindings.resolve.ts | 260 +++------------ src/acp/persistent-bindings.test.ts | 12 + src/acp/persistent-bindings.types.ts | 3 +- src/auto-reply/reply/commands-allowlist.ts | 304 ++++++------------ src/auto-reply/reply/commands.test.ts | 28 ++ src/channels/plugins/outbound/signal.ts | 130 ++++++-- src/channels/plugins/types.adapters.ts | 71 ++++ src/channels/plugins/types.core.ts | 6 + src/channels/plugins/types.plugin.ts | 2 + src/channels/plugins/types.ts | 1 + src/commands/channel-test-helpers.ts | 2 + ...tbeat-runner.returns-default-unset.test.ts | 15 + src/infra/outbound/deliver.ts | 191 +++++------ src/infra/outbound/targets.test.ts | 82 ++++- src/infra/outbound/targets.ts | 114 ++----- src/test-utils/channel-plugins.ts | 3 + 24 files changed, 1177 insertions(+), 646 deletions(-) diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 82532f4c43b..a16574bfb70 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -39,6 +39,7 @@ import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.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"; @@ -116,6 +117,84 @@ function hasDiscordExecApprovalDmRoute(cfg: OpenClawConfig): boolean { }); } +function readDiscordAllowlistConfig(account: ResolvedDiscordAccount) { + const groupOverrides: Array<{ label: string; entries: string[] }> = []; + for (const [guildKey, guildCfg] of Object.entries(account.config.guilds ?? {})) { + const entries = (guildCfg?.users ?? []).map(String).filter(Boolean); + if (entries.length > 0) { + groupOverrides.push({ label: `guild ${guildKey}`, entries }); + } + for (const [channelKey, channelCfg] of Object.entries(guildCfg?.channels ?? {})) { + const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean); + if (channelEntries.length > 0) { + groupOverrides.push({ + label: `guild ${guildKey} / channel ${channelKey}`, + entries: channelEntries, + }); + } + } + } + return { + dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String), + groupPolicy: account.config.groupPolicy, + groupOverrides, + }; +} + +async function resolveDiscordAllowlistNames(params: { + cfg: Parameters[0]["cfg"]; + accountId?: string | null; + entries: string[]; +}) { + const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); + const token = account.token?.trim(); + if (!token) { + return []; + } + return await resolveDiscordUserAllowlist({ token, entries: params.entries }); +} + +function normalizeDiscordAcpConversationId(conversationId: string) { + const normalized = conversationId.trim(); + return normalized ? { conversationId: normalized } : null; +} + +function matchDiscordAcpConversation(params: { + bindingConversationId: string; + conversationId: string; + parentConversationId?: string; +}) { + if (params.bindingConversationId === params.conversationId) { + return { conversationId: params.conversationId, matchPriority: 2 }; + } + if ( + params.parentConversationId && + params.parentConversationId !== params.conversationId && + params.bindingConversationId === params.parentConversationId + ) { + return { + conversationId: params.parentConversationId, + matchPriority: 1, + }; + } + return null; +} + +function parseDiscordExplicitTarget(raw: string) { + try { + const target = parseDiscordTarget(raw, { defaultKind: "channel" }); + if (!target) { + return null; + } + return { + to: target.id, + chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const), + }; + } catch { + return null; + } +} + const discordConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, @@ -177,6 +256,23 @@ export const discordPlugin: ChannelPlugin = { }), ...discordConfigAccessors, }, + allowlist: { + supportsScope: ({ scope }) => scope === "dm", + readConfig: ({ cfg, accountId }) => + readDiscordAllowlistConfig(resolveDiscordAccount({ cfg, accountId })), + resolveNames: async ({ cfg, accountId, entries }) => + await resolveDiscordAllowlistNames({ cfg, accountId, entries }), + resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => + scope === "dm" + ? { + pathPrefix, + writeTarget, + readPaths: [["allowFrom"], ["dm", "allowFrom"]], + writePath: ["allowFrom"], + cleanupPaths: [["dm", "allowFrom"]], + } + : null, + }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ @@ -238,6 +334,8 @@ export const discordPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeDiscordMessagingTarget, + parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw), + inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType, buildCrossContextComponents: buildDiscordCrossContextComponents, targetResolver: { looksLikeId: looksLikeDiscordTargetId, @@ -356,6 +454,12 @@ export const discordPlugin: ChannelPlugin = { silent: silent ?? undefined, }), }, + acpBindings: { + normalizeConfiguredBindingTarget: ({ conversationId }) => + normalizeDiscordAcpConversationId(conversationId), + matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) => + matchDiscordAcpConversation({ bindingConversationId, conversationId, parentConversationId }), + }, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index a9aed9f870d..450b1fbe88f 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -22,6 +22,7 @@ import { resolveDefaultFeishuAccountId, } from "./accounts.js"; import { FeishuConfigSchema } from "./config-schema.js"; +import { parseFeishuConversationId } from "./conversation-id.js"; import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { getFeishuRuntime } from "./runtime.js"; @@ -95,6 +96,77 @@ function areAnyFeishuReactionActionsEnabled(cfg: ClawdbotConfig): boolean { return false; } +function isSupportedFeishuDirectConversationId(conversationId: string): boolean { + const trimmed = conversationId.trim(); + if (!trimmed || trimmed.includes(":")) { + return false; + } + if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) { + return false; + } + return true; +} + +function normalizeFeishuAcpConversationId(conversationId: string) { + const parsed = parseFeishuConversationId({ conversationId }); + if ( + !parsed || + (parsed.scope !== "group_topic" && + parsed.scope !== "group_topic_sender" && + !isSupportedFeishuDirectConversationId(parsed.canonicalConversationId)) + ) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: + parsed.scope === "group_topic" || parsed.scope === "group_topic_sender" + ? parsed.chatId + : undefined, + }; +} + +function matchFeishuAcpConversation(params: { + bindingConversationId: string; + conversationId: string; + parentConversationId?: string; +}) { + const binding = normalizeFeishuAcpConversationId(params.bindingConversationId); + if (!binding) { + return null; + } + const incoming = parseFeishuConversationId({ + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }); + if ( + !incoming || + (incoming.scope !== "group_topic" && + incoming.scope !== "group_topic_sender" && + !isSupportedFeishuDirectConversationId(incoming.canonicalConversationId)) + ) { + return null; + } + const matchesCanonicalConversation = binding.conversationId === incoming.canonicalConversationId; + const matchesParentTopicForSenderScopedConversation = + incoming.scope === "group_topic_sender" && + binding.parentConversationId === incoming.chatId && + binding.conversationId === `${incoming.chatId}:topic:${incoming.topicId}`; + if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) { + return null; + } + return { + conversationId: matchesParentTopicForSenderScopedConversation + ? binding.conversationId + : incoming.canonicalConversationId, + parentConversationId: + incoming.scope === "group_topic" || incoming.scope === "group_topic_sender" + ? incoming.chatId + : undefined, + matchPriority: matchesCanonicalConversation ? 2 : 1, + }; +} + export const feishuPlugin: ChannelPlugin = { id: "feishu", meta: { @@ -393,6 +465,12 @@ export const feishuPlugin: ChannelPlugin = { }); }, }, + acpBindings: { + normalizeConfiguredBindingTarget: ({ conversationId }) => + normalizeFeishuAcpConversationId(conversationId), + matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) => + matchFeishuAcpConversation({ bindingConversationId, conversationId, parentConversationId }), + }, setup: feishuSetupAdapter, setupWizard: feishuSetupWizard, messaging: { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index f2621dea5c2..aec66694ef8 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -124,6 +124,24 @@ export const imessagePlugin: ChannelPlugin = { formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), }, + allowlist: { + supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", + readConfig: ({ cfg, accountId }) => { + const account = resolveIMessageAccount({ cfg, accountId }); + return { + dmAllowFrom: (account.config.allowFrom ?? []).map(String), + groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), + dmPolicy: account.config.dmPolicy, + groupPolicy: account.config.groupPolicy, + }; + }, + resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({ + pathPrefix, + writeTarget, + readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], + writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + }), + }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 010df26d390..80291872143 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -26,7 +26,10 @@ import { type ChannelPlugin, type ResolvedSignalAccount, } 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 { markdownToSignalTextChunks } from "./format.js"; import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; @@ -66,12 +69,8 @@ const signalConfigAccessors = createScopedAccountConfigAccessors({ type SignalSendFn = ReturnType["channel"]["signal"]["sendMessageSignal"]; -async function sendSignalOutbound(params: { +function resolveSignalSendContext(params: { cfg: Parameters[0]["cfg"]; - to: string; - text: string; - mediaUrl?: string; - mediaLocalRoots?: readonly string[]; accountId?: string; deps?: { [channelId: string]: unknown }; }) { @@ -84,6 +83,19 @@ async function sendSignalOutbound(params: { cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb, accountId: params.accountId, }); + return { send, maxBytes }; +} + +async function sendSignalOutbound(params: { + cfg: Parameters[0]["cfg"]; + to: string; + text: string; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + accountId?: string; + deps?: { [channelId: string]: unknown }; +}) { + const { send, maxBytes } = resolveSignalSendContext(params); return await send(params.to, params.text, { cfg: params.cfg, ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), @@ -93,6 +105,120 @@ async function sendSignalOutbound(params: { }); } +function inferSignalTargetChatType(rawTo: string) { + let to = rawTo.trim(); + if (!to) { + return undefined; + } + if (/^signal:/i.test(to)) { + to = to.replace(/^signal:/i, "").trim(); + } + if (!to) { + return undefined; + } + const lower = to.toLowerCase(); + if (lower.startsWith("group:")) { + return "group" as const; + } + if (lower.startsWith("username:") || lower.startsWith("u:")) { + return "direct" as const; + } + return "direct" as const; +} + +function parseSignalExplicitTarget(raw: string) { + const normalized = normalizeSignalMessagingTarget(raw); + if (!normalized) { + return null; + } + return { + to: normalized, + chatType: inferSignalTargetChatType(normalized), + }; +} + +async function sendFormattedSignalText(ctx: { + cfg: Parameters[0]["cfg"]; + to: string; + text: string; + accountId?: string | null; + deps?: { [channelId: string]: unknown }; + abortSignal?: AbortSignal; +}) { + const { send, maxBytes } = resolveSignalSendContext({ + cfg: ctx.cfg, + accountId: ctx.accountId ?? undefined, + deps: ctx.deps, + }); + const limit = resolveTextChunkLimit(ctx.cfg, "signal", ctx.accountId ?? undefined, { + fallbackLimit: 4000, + }); + const tableMode = resolveMarkdownTableMode({ + cfg: ctx.cfg, + channel: "signal", + accountId: ctx.accountId ?? undefined, + }); + let chunks = + limit === undefined + ? markdownToSignalTextChunks(ctx.text, Number.POSITIVE_INFINITY, { tableMode }) + : markdownToSignalTextChunks(ctx.text, limit, { tableMode }); + if (chunks.length === 0 && ctx.text) { + chunks = [{ text: ctx.text, styles: [] }]; + } + const results = []; + for (const chunk of chunks) { + ctx.abortSignal?.throwIfAborted(); + const result = await send(ctx.to, chunk.text, { + cfg: ctx.cfg, + maxBytes, + accountId: ctx.accountId ?? undefined, + textMode: "plain", + textStyles: chunk.styles, + }); + results.push({ channel: "signal" as const, ...result }); + } + return results; +} + +async function sendFormattedSignalMedia(ctx: { + cfg: Parameters[0]["cfg"]; + to: string; + text: string; + mediaUrl: string; + mediaLocalRoots?: readonly string[]; + accountId?: string | null; + deps?: { [channelId: string]: unknown }; + abortSignal?: AbortSignal; +}) { + ctx.abortSignal?.throwIfAborted(); + const { send, maxBytes } = resolveSignalSendContext({ + cfg: ctx.cfg, + accountId: ctx.accountId ?? undefined, + deps: ctx.deps, + }); + const tableMode = resolveMarkdownTableMode({ + cfg: ctx.cfg, + channel: "signal", + accountId: ctx.accountId ?? undefined, + }); + const formatted = markdownToSignalTextChunks(ctx.text, Number.POSITIVE_INFINITY, { + tableMode, + })[0] ?? { + text: ctx.text, + styles: [], + }; + const result = await send(ctx.to, formatted.text, { + cfg: ctx.cfg, + mediaUrl: ctx.mediaUrl, + mediaLocalRoots: ctx.mediaLocalRoots, + maxBytes, + accountId: ctx.accountId ?? undefined, + textMode: "plain", + textStyles: formatted.styles, + }); + return { channel: "signal" as const, ...result }; +} + export const signalPlugin: ChannelPlugin = { id: "signal", meta: { @@ -146,6 +272,24 @@ export const signalPlugin: ChannelPlugin = { }), ...signalConfigAccessors, }, + allowlist: { + supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", + readConfig: ({ cfg, accountId }) => { + const account = resolveSignalAccount({ cfg, accountId }); + return { + dmAllowFrom: (account.config.allowFrom ?? []).map(String), + groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), + dmPolicy: account.config.dmPolicy, + groupPolicy: account.config.groupPolicy, + }; + }, + resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({ + pathPrefix, + writeTarget, + readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], + writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + }), + }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ @@ -174,6 +318,8 @@ export const signalPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeSignalMessagingTarget, + parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw), + inferTargetChatType: ({ to }) => inferSignalTargetChatType(to), targetResolver: { looksLikeId: looksLikeSignalTargetId, hint: "", @@ -185,6 +331,35 @@ export const signalPlugin: ChannelPlugin = { chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit), chunkerMode: "text", textChunkLimit: 4000, + sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => + await sendFormattedSignalText({ + cfg, + to, + text, + accountId, + deps, + abortSignal, + }), + sendFormattedMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + abortSignal, + }) => + await sendFormattedSignalMedia({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + abortSignal, + }), sendText: async ({ cfg, to, text, accountId, deps }) => { const result = await sendSignalOutbound({ cfg, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index f658b93d2c3..2a8849b1671 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -38,6 +38,7 @@ import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { parseSlackBlocksInput } from "./blocks-input.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"; @@ -129,6 +130,17 @@ function resolveSlackAutoThreadId(params: { return context.currentThreadTs; } +function parseSlackExplicitTarget(raw: string) { + const target = parseSlackTarget(raw, { defaultKind: "channel" }); + if (!target) { + return null; + } + return { + to: target.id, + chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const), + }; +} + function formatSlackScopeDiagnostic(params: { tokenType: "bot" | "user"; result: Awaited>; @@ -144,6 +156,32 @@ function formatSlackScopeDiagnostic(params: { } as const; } +function readSlackAllowlistConfig(account: ResolvedSlackAccount) { + return { + dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String), + groupPolicy: account.groupPolicy, + groupOverrides: Object.entries(account.channels ?? {}) + .map(([key, value]) => { + const entries = (value?.users ?? []).map(String).filter(Boolean); + return entries.length > 0 ? { label: key, entries } : null; + }) + .filter(Boolean) as Array<{ label: string; entries: string[] }>, + }; +} + +async function resolveSlackAllowlistNames(params: { + cfg: Parameters[0]["cfg"]; + accountId?: string | null; + entries: string[]; +}) { + const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); + const token = account.config.userToken?.trim() || account.botToken?.trim(); + if (!token) { + return []; + } + return await resolveSlackUserAllowlist({ token, entries: params.entries }); +} + const slackConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, @@ -235,6 +273,23 @@ export const slackPlugin: ChannelPlugin = { }), ...slackConfigAccessors, }, + allowlist: { + supportsScope: ({ scope }) => scope === "dm", + readConfig: ({ cfg, accountId }) => + readSlackAllowlistConfig(resolveSlackAccount({ cfg, accountId })), + resolveNames: async ({ cfg, accountId, entries }) => + await resolveSlackAllowlistNames({ cfg, accountId, entries }), + resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => + scope === "dm" + ? { + pathPrefix, + writeTarget, + readPaths: [["allowFrom"], ["dm", "allowFrom"]], + writePath: ["allowFrom"], + cleanupPaths: [["dm", "allowFrom"]], + } + : null, + }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ @@ -301,6 +356,8 @@ export const slackPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeSlackMessagingTarget, + parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw), + inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType, enableInteractiveReplies: ({ cfg, accountId }) => isSlackInteractiveRepliesEnabled({ cfg, accountId }), hasStructuredReplyPayload: ({ payload }) => { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index cbdb146b608..be09a186baf 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -37,6 +37,7 @@ import { type ResolvedTelegramAccount, type TelegramProbe, } 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 { @@ -166,6 +167,52 @@ function resolveTelegramAutoThreadId(params: { return context.currentThreadTs; } +function normalizeTelegramAcpConversationId(conversationId: string) { + const parsed = parseTelegramTopicConversation({ conversationId }); + if (!parsed || !parsed.chatId.startsWith("-")) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: parsed.chatId, + }; +} + +function matchTelegramAcpConversation(params: { + bindingConversationId: string; + conversationId: string; + parentConversationId?: string; +}) { + const binding = normalizeTelegramAcpConversationId(params.bindingConversationId); + if (!binding) { + return null; + } + const incoming = parseTelegramTopicConversation({ + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }); + if (!incoming || !incoming.chatId.startsWith("-")) { + return null; + } + if (binding.conversationId !== incoming.canonicalConversationId) { + return null; + } + return { + conversationId: incoming.canonicalConversationId, + parentConversationId: incoming.chatId, + matchPriority: 2, + }; +} + +function parseTelegramExplicitTarget(raw: string) { + const target = parseTelegramTarget(raw); + return { + to: target.chatId, + threadId: target.messageThreadId, + chatType: target.chatType === "unknown" ? undefined : target.chatType, + }; +} + function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean { return listTelegramAccountIds(cfg).some((accountId) => { if (!isTelegramExecApprovalClientEnabled({ cfg, accountId })) { @@ -217,6 +264,29 @@ const resolveTelegramDmPolicy = createScopedDmSecurityResolver raw.replace(/^(telegram|tg):/i, ""), }); +function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) { + const groupOverrides: Array<{ label: string; entries: string[] }> = []; + for (const [groupId, groupCfg] of Object.entries(account.config.groups ?? {})) { + const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean); + if (entries.length > 0) { + groupOverrides.push({ label: groupId, entries }); + } + for (const [topicId, topicCfg] of Object.entries(groupCfg?.topics ?? {})) { + const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean); + if (topicEntries.length > 0) { + groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries }); + } + } + } + return { + dmAllowFrom: (account.config.allowFrom ?? []).map(String), + groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), + dmPolicy: account.config.dmPolicy, + groupPolicy: account.config.groupPolicy, + groupOverrides, + }; +} + export const telegramPlugin: ChannelPlugin = { id: "telegram", meta: { @@ -284,6 +354,23 @@ export const telegramPlugin: ChannelPlugin scope === "dm" || scope === "group" || scope === "all", + readConfig: ({ cfg, accountId }) => + readTelegramAllowlistConfig(resolveTelegramAccount({ cfg, accountId })), + resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({ + pathPrefix, + writeTarget, + readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], + writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + }), + }, + acpBindings: { + normalizeConfiguredBindingTarget: ({ conversationId }) => + normalizeTelegramAcpConversationId(conversationId), + matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) => + matchTelegramAcpConversation({ bindingConversationId, conversationId, parentConversationId }), + }, security: { resolveDmPolicy: resolveTelegramDmPolicy, collectWarnings: ({ account, cfg }) => { @@ -325,6 +412,8 @@ export const telegramPlugin: ChannelPlugin parseTelegramExplicitTarget(raw), + inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType, targetResolver: { looksLikeId: looksLikeTelegramTargetId, hint: "", @@ -423,6 +512,9 @@ export const telegramPlugin: ChannelPlugin Boolean(payload.channelData), + resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => + typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, sendPayload: async ({ cfg, to, diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index e8c0530d06b..0ab050bbd06 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -102,6 +102,9 @@ export const telegramOutbound: ChannelOutboundAdapter = { chunker: markdownToTelegramHtmlChunks, chunkerMode: "markdown", textChunkLimit: 4000, + shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData), + resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => + typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => { const { send, baseOpts } = resolveTelegramSendContext({ cfg, diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index d73c951a054..cf506e6912b 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -24,6 +24,7 @@ import { type ChannelMessageActionName, type ChannelPlugin, } 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, @@ -42,6 +43,21 @@ async function loadWhatsAppChannelRuntime() { return await import("./channel.runtime.js"); } +function normalizeWhatsAppPayloadText(text: string | undefined): string { + return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); +} + +function parseWhatsAppExplicitTarget(raw: string) { + const normalized = normalizeWhatsAppTarget(raw); + if (!normalized) { + return null; + } + return { + to: normalized, + chatType: isWhatsAppGroupJid(normalized) ? ("group" as const) : ("direct" as const), + }; +} + const whatsappSetupWizardProxy = { channel: "whatsapp", status: { @@ -168,6 +184,24 @@ export const whatsappPlugin: ChannelPlugin = { formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), }, + allowlist: { + supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", + readConfig: ({ cfg, accountId }) => { + const account = resolveWhatsAppAccount({ cfg, accountId }); + return { + dmAllowFrom: (account.allowFrom ?? []).map(String), + groupAllowFrom: (account.groupAllowFrom ?? []).map(String), + dmPolicy: account.dmPolicy, + groupPolicy: account.groupPolicy, + }; + }, + resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({ + pathPrefix, + writeTarget, + readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], + writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + }), + }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ @@ -224,6 +258,8 @@ export const whatsappPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeWhatsAppMessagingTarget, + parseExplicitTarget: ({ raw }) => parseWhatsAppExplicitTarget(raw), + inferTargetChatType: ({ to }) => parseWhatsAppExplicitTarget(to)?.chatType, targetResolver: { looksLikeId: looksLikeWhatsAppTargetId, hint: "", @@ -288,16 +324,22 @@ export const whatsappPlugin: ChannelPlugin = { ); }, }, - outbound: createWhatsAppOutboundBase({ - chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit), - sendMessageWhatsApp: async (...args) => - await getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp(...args), - sendPollWhatsApp: async (...args) => - await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(...args), - shouldLogVerbose: () => getWhatsAppRuntime().logging.shouldLogVerbose(), - resolveTarget: ({ to, allowFrom, mode }) => - resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), - }), + outbound: { + ...createWhatsAppOutboundBase({ + chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit), + sendMessageWhatsApp: async (...args) => + await getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp(...args), + sendPollWhatsApp: async (...args) => + await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(...args), + shouldLogVerbose: () => getWhatsAppRuntime().logging.shouldLogVerbose(), + resolveTarget: ({ to, allowFrom, mode }) => + resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), + }), + normalizePayload: ({ payload }) => ({ + ...payload, + text: normalizeWhatsAppPayloadText(payload.text), + }), + }, auth: { login: async ({ cfg, accountId, runtime, verbose }) => { const resolvedAccountId = accountId?.trim() || resolveDefaultWhatsAppAccountId(cfg); diff --git a/src/acp/persistent-bindings.resolve.ts b/src/acp/persistent-bindings.resolve.ts index 66464535eae..d0039078378 100644 --- a/src/acp/persistent-bindings.resolve.ts +++ b/src/acp/persistent-bindings.resolve.ts @@ -1,4 +1,4 @@ -import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js"; +import { getChannelPlugin } from "../channels/plugins/index.js"; import { listAcpBindings } from "../config/bindings.js"; import type { OpenClawConfig } from "../config/config.js"; import type { AgentAcpBinding } from "../config/types.js"; @@ -8,7 +8,6 @@ import { normalizeAccountId, parseAgentSessionKey, } from "../routing/session-key.js"; -import { parseTelegramTopicConversation } from "./conversation-id.js"; import { buildConfiguredAcpSessionKey, normalizeBindingConfig, @@ -22,21 +21,11 @@ import { function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null { const normalized = (value ?? "").trim().toLowerCase(); - if (normalized === "discord" || normalized === "telegram" || normalized === "feishu") { - return normalized; + if (!normalized) { + return null; } - return null; -} - -function isSupportedFeishuDirectConversationId(conversationId: string): boolean { - const trimmed = conversationId.trim(); - if (!trimmed || trimmed.includes(":")) { - return false; - } - if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) { - return false; - } - return true; + const plugin = getChannelPlugin(normalized); + return plugin?.acpBindings ? plugin.id : null; } function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 { @@ -71,10 +60,9 @@ function parseConfiguredBindingSessionKey(params: { if (!channel) { return null; } - const accountId = normalizeAccountId(tokens[3]); return { channel, - accountId, + accountId: normalizeAccountId(tokens[3]), }; } @@ -231,6 +219,12 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: { if (!parsedSessionKey) { return null; } + const plugin = getChannelPlugin(parsedSessionKey.channel); + const acpBindings = plugin?.acpBindings; + if (!acpBindings?.normalizeConfiguredBindingTarget) { + return null; + } + let wildcardMatch: ConfiguredAcpBindingSpec | null = null; for (const binding of listAcpBindings(params.cfg)) { const channel = normalizeBindingChannel(binding.match.channel); @@ -248,81 +242,29 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: { if (!targetConversationId) { continue; } - if (channel === "discord") { - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel: "discord", - accountId: parsedSessionKey.accountId, - conversationId: targetConversationId, - binding, - }); - if (buildConfiguredAcpSessionKey(spec) === sessionKey) { - if (accountMatchPriority === 2) { - return spec; - } - if (!wildcardMatch) { - wildcardMatch = spec; - } - } - continue; - } - if (channel === "feishu") { - const targetParsed = parseFeishuConversationId({ - conversationId: targetConversationId, - }); - if ( - !targetParsed || - (targetParsed.scope !== "group_topic" && - targetParsed.scope !== "group_topic_sender" && - !isSupportedFeishuDirectConversationId(targetParsed.canonicalConversationId)) - ) { - continue; - } - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel: "feishu", - accountId: parsedSessionKey.accountId, - conversationId: targetParsed.canonicalConversationId, - // Session-key recovery deliberately collapses sender-scoped topic bindings onto the - // canonical topic conversation id so `group_topic` and `group_topic_sender` reuse - // the same configured ACP session identity. - parentConversationId: - targetParsed.scope === "group_topic" || targetParsed.scope === "group_topic_sender" - ? targetParsed.chatId - : undefined, - binding, - }); - if (buildConfiguredAcpSessionKey(spec) === sessionKey) { - if (accountMatchPriority === 2) { - return spec; - } - if (!wildcardMatch) { - wildcardMatch = spec; - } - } - continue; - } - const parsedTopic = parseTelegramTopicConversation({ + const target = acpBindings.normalizeConfiguredBindingTarget({ + binding, conversationId: targetConversationId, }); - if (!parsedTopic || !parsedTopic.chatId.startsWith("-")) { + if (!target) { continue; } const spec = toConfiguredBindingSpec({ cfg: params.cfg, - channel: "telegram", + channel, accountId: parsedSessionKey.accountId, - conversationId: parsedTopic.canonicalConversationId, - parentConversationId: parsedTopic.chatId, + conversationId: target.conversationId, + parentConversationId: target.parentConversationId, binding, }); - if (buildConfiguredAcpSessionKey(spec) === sessionKey) { - if (accountMatchPriority === 2) { - return spec; - } - if (!wildcardMatch) { - wildcardMatch = spec; - } + if (buildConfiguredAcpSessionKey(spec) !== sessionKey) { + continue; + } + if (accountMatchPriority === 2) { + return spec; + } + if (!wildcardMatch) { + wildcardMatch = spec; } } return wildcardMatch; @@ -335,136 +277,36 @@ export function resolveConfiguredAcpBindingRecord(params: { conversationId: string; parentConversationId?: string; }): ResolvedConfiguredAcpBinding | null { - const channel = params.channel.trim().toLowerCase(); + const channel = normalizeBindingChannel(params.channel); const accountId = normalizeAccountId(params.accountId); const conversationId = params.conversationId.trim(); const parentConversationId = params.parentConversationId?.trim() || undefined; - if (!conversationId) { + if (!channel || !conversationId) { return null; } + const plugin = getChannelPlugin(channel); + const acpBindings = plugin?.acpBindings; + if (!acpBindings?.matchConfiguredBinding) { + return null; + } + const matchConfiguredBinding = acpBindings.matchConfiguredBinding; - if (channel === "discord") { - const bindings = listAcpBindings(params.cfg); - const resolveDiscordBindingForConversation = (targetConversationId: string) => - resolveConfiguredBindingRecord({ - cfg: params.cfg, - bindings, - channel: "discord", - accountId, - selectConversation: (binding) => { - const bindingConversationId = resolveBindingConversationId(binding); - if (!bindingConversationId || bindingConversationId !== targetConversationId) { - return null; - } - return { conversationId: targetConversationId }; - }, - }); - - const directMatch = resolveDiscordBindingForConversation(conversationId); - if (directMatch) { - return directMatch; - } - if (parentConversationId && parentConversationId !== conversationId) { - const inheritedMatch = resolveDiscordBindingForConversation(parentConversationId); - if (inheritedMatch) { - return inheritedMatch; + return resolveConfiguredBindingRecord({ + cfg: params.cfg, + bindings: listAcpBindings(params.cfg), + channel, + accountId, + selectConversation: (binding) => { + const bindingConversationId = resolveBindingConversationId(binding); + if (!bindingConversationId) { + return null; } - } - return null; - } - - if (channel === "telegram") { - const parsed = parseTelegramTopicConversation({ - conversationId, - parentConversationId, - }); - if (!parsed || !parsed.chatId.startsWith("-")) { - return null; - } - return resolveConfiguredBindingRecord({ - cfg: params.cfg, - bindings: listAcpBindings(params.cfg), - channel: "telegram", - accountId, - selectConversation: (binding) => { - const targetConversationId = resolveBindingConversationId(binding); - if (!targetConversationId) { - return null; - } - const targetParsed = parseTelegramTopicConversation({ - conversationId: targetConversationId, - }); - if (!targetParsed || !targetParsed.chatId.startsWith("-")) { - return null; - } - if (targetParsed.canonicalConversationId !== parsed.canonicalConversationId) { - return null; - } - return { - conversationId: parsed.canonicalConversationId, - parentConversationId: parsed.chatId, - }; - }, - }); - } - - if (channel === "feishu") { - const parsed = parseFeishuConversationId({ - conversationId, - parentConversationId, - }); - if ( - !parsed || - (parsed.scope !== "group_topic" && - parsed.scope !== "group_topic_sender" && - !isSupportedFeishuDirectConversationId(parsed.canonicalConversationId)) - ) { - return null; - } - return resolveConfiguredBindingRecord({ - cfg: params.cfg, - bindings: listAcpBindings(params.cfg), - channel: "feishu", - accountId, - selectConversation: (binding) => { - const targetConversationId = resolveBindingConversationId(binding); - if (!targetConversationId) { - return null; - } - const targetParsed = parseFeishuConversationId({ - conversationId: targetConversationId, - }); - if ( - !targetParsed || - (targetParsed.scope !== "group_topic" && - targetParsed.scope !== "group_topic_sender" && - !isSupportedFeishuDirectConversationId(targetParsed.canonicalConversationId)) - ) { - return null; - } - const matchesCanonicalConversation = - targetParsed.canonicalConversationId === parsed.canonicalConversationId; - const matchesParentTopicForSenderScopedConversation = - parsed.scope === "group_topic_sender" && - targetParsed.scope === "group_topic" && - parsed.chatId === targetParsed.chatId && - parsed.topicId === targetParsed.topicId; - if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) { - return null; - } - return { - conversationId: matchesParentTopicForSenderScopedConversation - ? targetParsed.canonicalConversationId - : parsed.canonicalConversationId, - parentConversationId: - parsed.scope === "group_topic" || parsed.scope === "group_topic_sender" - ? parsed.chatId - : undefined, - matchPriority: matchesCanonicalConversation ? 2 : 1, - }; - }, - }); - } - - return null; + return matchConfiguredBinding({ + binding, + bindingConversationId, + conversationId, + parentConversationId, + }); + }, + }); } diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index 06bfba46d57..147c4a455c9 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -1,5 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { discordPlugin } from "../../extensions/discord/src/channel.js"; +import { feishuPlugin } from "../../extensions/feishu/src/channel.js"; +import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import type { OpenClawConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; const managerMocks = vi.hoisted(() => ({ resolveSession: vi.fn(), closeSession: vi.fn(), @@ -162,6 +167,13 @@ function mockReadySession(params: { spec: BindingSpec; cwd: string }) { } beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "discord", plugin: discordPlugin, source: "test" }, + { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, + { pluginId: "feishu", plugin: feishuPlugin, source: "test" }, + ]), + ); managerMocks.resolveSession.mockReset(); managerMocks.closeSession.mockReset().mockResolvedValue({ runtimeClosed: true, diff --git a/src/acp/persistent-bindings.types.ts b/src/acp/persistent-bindings.types.ts index 3864392c96c..3583fc4cd9f 100644 --- a/src/acp/persistent-bindings.types.ts +++ b/src/acp/persistent-bindings.types.ts @@ -1,9 +1,10 @@ import { createHash } from "node:crypto"; +import type { ChannelId } from "../channels/plugins/types.js"; import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js"; import { sanitizeAgentId } from "../routing/session-key.js"; import type { AcpRuntimeSessionMode } from "./runtime/types.js"; -export type ConfiguredAcpBindingChannel = "discord" | "telegram" | "feishu"; +export type ConfiguredAcpBindingChannel = ChannelId; export type ConfiguredAcpBindingSpec = { channel: ConfiguredAcpBindingChannel; diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index 83d263b828c..f371fcd0b62 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -1,13 +1,4 @@ -import { resolveDiscordAccount } from "../../../extensions/discord/src/accounts.js"; -import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js"; -import { resolveIMessageAccount } from "../../../extensions/imessage/src/accounts.js"; -import { resolveSignalAccount } from "../../../extensions/signal/src/accounts.js"; -import { resolveSlackAccount } from "../../../extensions/slack/src/accounts.js"; -import { resolveSlackUserAllowlist } from "../../../extensions/slack/src/resolve-users.js"; -import { resolveTelegramAccount } from "../../../extensions/telegram/src/accounts.js"; -import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/src/accounts.js"; -import { getChannelDock } from "../../channels/dock.js"; -import { resolveExplicitConfigWriteTarget } from "../../channels/plugins/config-writes.js"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; import { listPairingChannels } from "../../channels/plugins/pairing.js"; import type { ChannelId } from "../../channels/plugins/types.js"; import { normalizeChannelId } from "../../channels/registry.js"; @@ -159,9 +150,9 @@ function normalizeAllowFrom(params: { accountId?: string | null; values: Array; }): string[] { - const dock = getChannelDock(params.channelId); - if (dock?.config?.formatAllowFrom) { - return dock.config.formatAllowFrom({ + const plugin = getChannelPlugin(params.channelId); + if (plugin?.config.formatAllowFrom) { + return plugin.config.formatAllowFrom({ cfg: params.cfg, accountId: params.accountId, allowFrom: params.values, @@ -182,22 +173,6 @@ function formatEntryList(entries: string[], resolved?: Map): str .join(", "); } -function extractConfigAllowlist(account: { - config?: { - allowFrom?: Array; - groupAllowFrom?: Array; - dmPolicy?: string; - groupPolicy?: string; - }; -}) { - return { - dmAllowFrom: (account.config?.allowFrom ?? []).map(String), - groupAllowFrom: (account.config?.groupAllowFrom ?? []).map(String), - dmPolicy: account.config?.dmPolicy, - groupPolicy: account.config?.groupPolicy, - }; -} - async function updatePairingStoreAllowlist(params: { action: "add" | "remove"; channelId: ChannelId; @@ -236,7 +211,7 @@ function resolveAccountTarget( target: channel, pathPrefix: `channels.${channelId}`, accountId: DEFAULT_ACCOUNT_ID, - writeTarget: resolveExplicitConfigWriteTarget({ channelId }), + writeTarget: { kind: "channel", scope: { channelId } } as const, }; } const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object"); @@ -246,7 +221,7 @@ function resolveAccountTarget( target: channel, pathPrefix: `channels.${channelId}`, accountId: normalizedAccountId, - writeTarget: resolveExplicitConfigWriteTarget({ channelId }), + writeTarget: { kind: "channel", scope: { channelId } } as const, }; } const accounts = (channel.accounts ??= {}) as Record; @@ -261,10 +236,10 @@ function resolveAccountTarget( target: account, pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`, accountId: normalizedAccountId, - writeTarget: resolveExplicitConfigWriteTarget({ - channelId, - accountId: normalizedAccountId, - }), + writeTarget: { + kind: "account", + scope: { channelId, accountId: normalizedAccountId }, + } as const, }; } @@ -321,37 +296,6 @@ function deleteNestedValue(root: Record, path: string[]) { delete (parent as Record)[path[path.length - 1]]; } -function resolveChannelAllowFromPaths( - channelId: ChannelId, - scope: AllowlistScope, -): string[] | null { - const supportsGroupAllowlist = - channelId === "telegram" || - channelId === "whatsapp" || - channelId === "signal" || - channelId === "imessage"; - if (scope === "all") { - return null; - } - if (scope === "dm") { - if (channelId === "slack" || channelId === "discord") { - // Canonical DM allowlist location for Slack/Discord. Legacy: dm.allowFrom. - return ["allowFrom"]; - } - if (supportsGroupAllowlist) { - return ["allowFrom"]; - } - return null; - } - if (scope === "group") { - if (supportsGroupAllowlist) { - return ["groupAllowFrom"]; - } - return null; - } - return null; -} - function mapResolvedAllowlistNames(entries: ResolvedAllowlistName[]): Map { const map = new Map(); for (const entry of entries) { @@ -362,32 +306,35 @@ function mapResolvedAllowlistNames(entries: ResolvedAllowlistName[]): Map(); - } - const resolved = await resolveSlackUserAllowlist({ token, entries: params.entries }); - return mapResolvedAllowlistNames(resolved); + const plugin = getChannelPlugin(params.channelId); + const resolved = await plugin?.allowlist?.resolveNames?.({ + cfg: params.cfg, + accountId: params.accountId, + scope: params.scope, + entries: params.entries, + }); + return mapResolvedAllowlistNames(resolved ?? []); } -async function resolveDiscordNames(params: { +async function readAllowlistConfig(params: { cfg: OpenClawConfig; + channelId: ChannelId; accountId?: string | null; - entries: string[]; }) { - const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); - const token = account.token?.trim(); - if (!token) { - return new Map(); - } - const resolved = await resolveDiscordUserAllowlist({ token, entries: params.entries }); - return mapResolvedAllowlistNames(resolved); + const plugin = getChannelPlugin(params.channelId); + return ( + (await plugin?.allowlist?.readConfig?.({ + cfg: params.cfg, + accountId: params.accountId, + })) ?? {} + ); } export const handleAllowlistCommand: CommandHandler = async (params, allowTextCommands) => { @@ -425,83 +372,31 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }; } const accountId = normalizeAccountId(parsed.account ?? params.ctx.AccountId); - const scope = parsed.scope; + const plugin = getChannelPlugin(channelId); if (parsed.action === "list") { - const pairingChannels = listPairingChannels(); - const supportsStore = pairingChannels.includes(channelId); + const supportsStore = listPairingChannels().includes(channelId); + if (!plugin?.allowlist?.readConfig && !supportsStore) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${channelId} does not expose allowlist configuration.` }, + }; + } const storeAllowFrom = supportsStore ? await readChannelAllowFromStore(channelId, process.env, accountId).catch(() => []) : []; + const configState = await readAllowlistConfig({ + cfg: params.cfg, + channelId, + accountId, + }); - let dmAllowFrom: string[] = []; - let groupAllowFrom: string[] = []; - let groupOverrides: Array<{ label: string; entries: string[] }> = []; - let dmPolicy: string | undefined; - let groupPolicy: string | undefined; - - if (channelId === "telegram") { - const account = resolveTelegramAccount({ cfg: params.cfg, accountId }); - ({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account)); - const groups = account.config.groups ?? {}; - for (const [groupId, groupCfg] of Object.entries(groups)) { - const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean); - if (entries.length > 0) { - groupOverrides.push({ label: groupId, entries }); - } - const topics = groupCfg?.topics ?? {}; - for (const [topicId, topicCfg] of Object.entries(topics)) { - const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean); - if (topicEntries.length > 0) { - groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries }); - } - } - } - } else if (channelId === "whatsapp") { - const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId }); - dmAllowFrom = (account.allowFrom ?? []).map(String); - groupAllowFrom = (account.groupAllowFrom ?? []).map(String); - dmPolicy = account.dmPolicy; - groupPolicy = account.groupPolicy; - } else if (channelId === "signal") { - const account = resolveSignalAccount({ cfg: params.cfg, accountId }); - ({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account)); - } else if (channelId === "imessage") { - const account = resolveIMessageAccount({ cfg: params.cfg, accountId }); - ({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account)); - } else if (channelId === "slack") { - const account = resolveSlackAccount({ cfg: params.cfg, accountId }); - dmAllowFrom = (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String); - groupPolicy = account.groupPolicy; - const channels = account.channels ?? {}; - groupOverrides = Object.entries(channels) - .map(([key, value]) => { - const entries = (value?.users ?? []).map(String).filter(Boolean); - return entries.length > 0 ? { label: key, entries } : null; - }) - .filter(Boolean) as Array<{ label: string; entries: string[] }>; - } else if (channelId === "discord") { - const account = resolveDiscordAccount({ cfg: params.cfg, accountId }); - dmAllowFrom = (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String); - groupPolicy = account.config.groupPolicy; - const guilds = account.config.guilds ?? {}; - for (const [guildKey, guildCfg] of Object.entries(guilds)) { - const entries = (guildCfg?.users ?? []).map(String).filter(Boolean); - if (entries.length > 0) { - groupOverrides.push({ label: `guild ${guildKey}`, entries }); - } - const channels = guildCfg?.channels ?? {}; - for (const [channelKey, channelCfg] of Object.entries(channels)) { - const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean); - if (channelEntries.length > 0) { - groupOverrides.push({ - label: `guild ${guildKey} / channel ${channelKey}`, - entries: channelEntries, - }); - } - } - } - } + const dmAllowFrom = (configState.dmAllowFrom ?? []).map(String); + const groupAllowFrom = (configState.groupAllowFrom ?? []).map(String); + const groupOverrides = (configState.groupOverrides ?? []).map((entry) => ({ + label: entry.label, + entries: entry.entries.map(String).filter(Boolean), + })); const dmDisplay = normalizeAllowFrom({ cfg: params.cfg, @@ -522,38 +417,39 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo accountId, values: groupOverrideEntries, }); + const resolvedDm = - parsed.resolve && dmDisplay.length > 0 && channelId === "slack" - ? await resolveSlackNames({ cfg: params.cfg, accountId, entries: dmDisplay }) - : parsed.resolve && dmDisplay.length > 0 && channelId === "discord" - ? await resolveDiscordNames({ cfg: params.cfg, accountId, entries: dmDisplay }) - : undefined; - const resolvedGroup = - parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "slack" - ? await resolveSlackNames({ + parsed.resolve && dmDisplay.length > 0 + ? await resolveAllowlistNames({ cfg: params.cfg, + channelId, accountId, + scope: "dm", + entries: dmDisplay, + }) + : undefined; + const resolvedGroup = + parsed.resolve && groupOverrideDisplay.length > 0 + ? await resolveAllowlistNames({ + cfg: params.cfg, + channelId, + accountId, + scope: "group", entries: groupOverrideDisplay, }) - : parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "discord" - ? await resolveDiscordNames({ - cfg: params.cfg, - accountId, - entries: groupOverrideDisplay, - }) - : undefined; + : undefined; const lines: string[] = ["🧾 Allowlist"]; lines.push(`Channel: ${channelId}${accountId ? ` (account ${accountId})` : ""}`); - if (dmPolicy) { - lines.push(`DM policy: ${dmPolicy}`); + if (configState.dmPolicy) { + lines.push(`DM policy: ${configState.dmPolicy}`); } - if (groupPolicy) { - lines.push(`Group policy: ${groupPolicy}`); + if (configState.groupPolicy) { + lines.push(`Group policy: ${configState.groupPolicy}`); } - const showDm = scope === "dm" || scope === "all"; - const showGroup = scope === "group" || scope === "all"; + const showDm = parsed.scope === "dm" || parsed.scope === "all"; + const showGroup = parsed.scope === "group" || parsed.scope === "all"; if (showDm) { lines.push(`DM allowFrom (config): ${formatEntryList(dmDisplay, resolvedDm)}`); } @@ -568,7 +464,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo } if (showGroup) { if (groupAllowFrom.length > 0) { - lines.push(`Group allowFrom (config): ${formatEntryList(groupDisplay)}`); + lines.push(`Group allowFrom (config): ${formatEntryList(groupDisplay, resolvedGroup)}`); } if (groupOverrides.length > 0) { lines.push("Group overrides:"); @@ -600,12 +496,29 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId); if (shouldUpdateConfig) { - const allowlistPath = resolveChannelAllowFromPaths(channelId, scope); - if (!allowlistPath) { + if (parsed.scope === "all") { + return { + shouldContinue: false, + reply: { text: "⚠️ /allowlist add|remove requires scope dm or group." }, + }; + } + const { + target, + pathPrefix, + accountId: normalizedAccountId, + writeTarget, + } = resolveAccountTarget(structuredClone({ channels: {} }), channelId, accountId); + void target; + const editSpec = plugin?.allowlist?.resolveConfigEdit?.({ + scope: parsed.scope, + pathPrefix, + writeTarget, + }); + if (!editSpec) { return { shouldContinue: false, reply: { - text: `⚠️ ${channelId} does not support ${scope} allowlist edits via /allowlist.`, + text: `⚠️ ${channelId} does not support ${parsed.scope} allowlist edits via /allowlist.`, }, }; } @@ -618,19 +531,14 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }; } const parsedConfig = structuredClone(snapshot.parsed as Record); - const { - target, - pathPrefix, - accountId: normalizedAccountId, - writeTarget, - } = resolveAccountTarget(parsedConfig, channelId, accountId); + const resolvedTarget = resolveAccountTarget(parsedConfig, channelId, accountId); const deniedText = resolveConfigWriteDeniedText({ cfg: params.cfg, channel: params.command.channel, channelId, accountId: params.ctx.AccountId, gatewayClientScopes: params.ctx.GatewayClientScopes, - target: writeTarget, + target: editSpec.writeTarget, }); if (deniedText) { return { @@ -642,13 +550,8 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo } const existing: string[] = []; - const existingPaths = - scope === "dm" && (channelId === "slack" || channelId === "discord") - ? // Read both while legacy alias may still exist; write canonical below. - [allowlistPath, ["dm", "allowFrom"]] - : [allowlistPath]; - for (const path of existingPaths) { - const existingRaw = getNestedValue(target, path); + for (const path of editSpec.readPaths) { + const existingRaw = getNestedValue(resolvedTarget.target, path); if (!Array.isArray(existingRaw)) { continue; } @@ -713,13 +616,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo if (configChanged) { if (next.length === 0) { - deleteNestedValue(target, allowlistPath); + deleteNestedValue(resolvedTarget.target, editSpec.writePath); } else { - setNestedValue(target, allowlistPath, next); + setNestedValue(resolvedTarget.target, editSpec.writePath, next); } - if (scope === "dm" && (channelId === "slack" || channelId === "discord")) { - // Remove legacy DM allowlist alias to prevent drift. - deleteNestedValue(target, ["dm", "allowFrom"]); + for (const path of editSpec.cleanupPaths ?? []) { + deleteNestedValue(resolvedTarget.target, path); } } @@ -750,10 +652,10 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo } const actionLabel = parsed.action === "add" ? "added" : "removed"; - const scopeLabel = scope === "dm" ? "DM" : "group"; + const scopeLabel = parsed.scope === "dm" ? "DM" : "group"; const locations: string[] = []; if (configChanged) { - locations.push(`${pathPrefix}.${allowlistPath.join(".")}`); + locations.push(`${resolvedTarget.pathPrefix}.${editSpec.writePath.join(".")}`); } if (shouldTouchStore) { locations.push("pairing store"); @@ -782,7 +684,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }); const actionLabel = parsed.action === "add" ? "added" : "removed"; - const scopeLabel = scope === "dm" ? "DM" : "group"; + const scopeLabel = parsed.scope === "dm" ? "DM" : "group"; return { shouldContinue: false, reply: { text: `✅ ${scopeLabel} allowlist ${actionLabel} in pairing store.` }, diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 2d8e6458933..0f2853aab98 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -8,6 +8,7 @@ import { listSubagentRunsForRequester, resetSubagentRegistryForTests, } from "../../agents/subagent-registry.js"; +import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; import { updateSessionStore, type SessionEntry } from "../../config/sessions.js"; import * as internalHooks from "../../hooks/internal-hooks.js"; @@ -133,6 +134,32 @@ afterAll(async () => { await fs.rm(testWorkspaceDir, { recursive: true, force: true }); }); +beforeEach(() => { + setDefaultChannelPluginRegistryForTests(); + readConfigFileSnapshotMock.mockImplementation(async () => { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + return { valid: false, parsed: null }; + } + const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record; + return { valid: true, parsed }; + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + writeConfigFileMock.mockImplementation(async (config: unknown) => { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + return; + } + await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8"); + }); + readChannelAllowFromStoreMock.mockResolvedValue([]); + addChannelAllowFromStoreEntryMock.mockResolvedValue({ changed: true, allowFrom: [] }); + removeChannelAllowFromStoreEntryMock.mockResolvedValue({ changed: true, allowFrom: [] }); +}); + async function withTempConfigPath( initialConfig: Record, run: (configPath: string) => Promise, @@ -998,6 +1025,7 @@ function buildPolicyParams( describe("handleCommands /allowlist", () => { beforeEach(() => { vi.clearAllMocks(); + setDefaultChannelPluginRegistryForTests(); }); it("lists config + store allowFrom entries", async () => { diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts index 028192a3f54..9de4e6f0fa7 100644 --- a/src/channels/plugins/outbound/signal.ts +++ b/src/channels/plugins/outbound/signal.ts @@ -1,31 +1,125 @@ +import { markdownToSignalTextChunks } from "../../../../extensions/signal/src/format.js"; import { sendMessageSignal } from "../../../../extensions/signal/src/send.js"; +import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js"; +import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js"; import { resolveOutboundSendDep, type OutboundSendDeps, } from "../../../infra/outbound/send-deps.js"; -import { - createScopedChannelMediaMaxBytesResolver, - createDirectTextMediaOutbound, -} from "./direct-text-media.js"; +import type { ChannelOutboundAdapter } from "../types.js"; +import { createScopedChannelMediaMaxBytesResolver } from "./direct-text-media.js"; function resolveSignalSender(deps: OutboundSendDeps | undefined) { return resolveOutboundSendDep(deps, "signal") ?? sendMessageSignal; } -export const signalOutbound = createDirectTextMediaOutbound({ - channel: "signal", - resolveSender: resolveSignalSender, - resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("signal"), - buildTextOptions: ({ cfg, maxBytes, accountId }) => ({ - cfg, - maxBytes, - accountId: accountId ?? undefined, - }), - buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, mediaLocalRoots }) => ({ +const resolveSignalMaxBytes = createScopedChannelMediaMaxBytesResolver("signal"); +type SignalSendOpts = NonNullable[2]>; + +function inferSignalTableMode(params: { cfg: SignalSendOpts["cfg"]; accountId?: string | null }) { + return resolveMarkdownTableMode({ + cfg: params.cfg, + channel: "signal", + accountId: params.accountId ?? undefined, + }); +} + +export const signalOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: (text, _limit) => text.split(/\n{2,}/).flatMap((chunk) => (chunk ? [chunk] : [])), + chunkerMode: "text", + textChunkLimit: 4000, + sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => { + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + const limit = resolveTextChunkLimit(cfg, "signal", accountId ?? undefined, { + fallbackLimit: 4000, + }); + const tableMode = inferSignalTableMode({ cfg, accountId }); + let chunks = + limit === undefined + ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { tableMode }) + : markdownToSignalTextChunks(text, limit, { tableMode }); + if (chunks.length === 0 && text) { + chunks = [{ text, styles: [] }]; + } + const results = []; + for (const chunk of chunks) { + abortSignal?.throwIfAborted(); + const result = await send(to, chunk.text, { + cfg, + maxBytes, + accountId: accountId ?? undefined, + textMode: "plain", + textStyles: chunk.styles, + }); + results.push({ channel: "signal" as const, ...result }); + } + return results; + }, + sendFormattedMedia: async ({ cfg, + to, + text, mediaUrl, - maxBytes, - accountId: accountId ?? undefined, mediaLocalRoots, - }), -}); + accountId, + deps, + abortSignal, + }) => { + abortSignal?.throwIfAborted(); + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + const tableMode = inferSignalTableMode({ cfg, accountId }); + const formatted = markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { + tableMode, + })[0] ?? { + text, + styles: [], + }; + const result = await send(to, formatted.text, { + cfg, + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + textMode: "plain", + textStyles: formatted.styles, + mediaLocalRoots, + }); + return { channel: "signal", ...result }; + }, + sendText: async ({ cfg, to, text, accountId, deps }) => { + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + const result = await send(to, text, { + cfg, + maxBytes, + accountId: accountId ?? undefined, + }); + return { channel: "signal", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + const result = await send(to, text, { + cfg, + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + mediaLocalRoots, + }); + return { channel: "signal", ...result }; + }, +}; diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 084fa653bb8..c66fa0d463e 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -1,11 +1,13 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { AgentAcpBinding } from "../../config/types.js"; import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js"; import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js"; import type { OutboundIdentity } from "../../infra/outbound/identity.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; import type { RuntimeEnv } from "../../runtime.js"; +import type { ConfigWriteTarget } from "./config-writes.js"; import type { ChannelAccountSnapshot, ChannelAccountState, @@ -137,12 +139,23 @@ export type ChannelOutboundPayloadContext = ChannelOutboundContext & { payload: ReplyPayload; }; +export type ChannelOutboundFormattedContext = ChannelOutboundContext & { + abortSignal?: AbortSignal; +}; + export type ChannelOutboundAdapter = { deliveryMode: "direct" | "gateway" | "hybrid"; chunker?: ((text: string, limit: number) => string[]) | null; chunkerMode?: "text" | "markdown"; textChunkLimit?: number; pollMaxOptions?: number; + normalizePayload?: (params: { payload: ReplyPayload }) => ReplyPayload | null; + shouldSkipPlainTextSanitization?: (params: { payload: ReplyPayload }) => boolean; + resolveEffectiveTextChunkLimit?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + fallbackLimit?: number; + }) => number | undefined; resolveTarget?: (params: { cfg?: OpenClawConfig; to?: string; @@ -151,6 +164,10 @@ export type ChannelOutboundAdapter = { mode?: ChannelOutboundTargetMode; }) => { ok: true; to: string } | { ok: false; error: Error }; sendPayload?: (ctx: ChannelOutboundPayloadContext) => Promise; + sendFormattedText?: (ctx: ChannelOutboundFormattedContext) => Promise; + sendFormattedMedia?: ( + ctx: ChannelOutboundFormattedContext & { mediaUrl: string }, + ) => Promise; sendText?: (ctx: ChannelOutboundContext) => Promise; sendMedia?: (ctx: ChannelOutboundContext) => Promise; sendPoll?: (ctx: ChannelPollContext) => Promise; @@ -464,9 +481,63 @@ export type ChannelExecApprovalAdapter = { }; export type ChannelAllowlistAdapter = { + readConfig?: (params: { cfg: OpenClawConfig; accountId?: string | null }) => + | { + dmAllowFrom?: Array; + groupAllowFrom?: Array; + dmPolicy?: string; + groupPolicy?: string; + groupOverrides?: Array<{ label: string; entries: Array }>; + } + | Promise<{ + dmAllowFrom?: Array; + groupAllowFrom?: Array; + dmPolicy?: string; + groupPolicy?: string; + groupOverrides?: Array<{ label: string; entries: Array }>; + }>; + resolveNames?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + scope: "dm" | "group"; + entries: string[]; + }) => + | Array<{ input: string; resolved: boolean; name?: string | null }> + | Promise>; + resolveConfigEdit?: (params: { + scope: "dm" | "group"; + pathPrefix: string; + writeTarget: ConfigWriteTarget; + }) => { + pathPrefix: string; + writeTarget: ConfigWriteTarget; + readPaths: string[][]; + writePath: string[]; + cleanupPaths?: string[][]; + } | null; supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean; }; +export type ChannelAcpBindingAdapter = { + normalizeConfiguredBindingTarget?: (params: { + binding: AgentAcpBinding; + conversationId: string; + }) => { + conversationId: string; + parentConversationId?: string; + } | null; + matchConfiguredBinding?: (params: { + binding: AgentAcpBinding; + bindingConversationId: string; + conversationId: string; + parentConversationId?: string; + }) => { + conversationId: string; + parentConversationId?: string; + matchPriority?: number; + } | null; +}; + export type ChannelSecurityAdapter = { resolveDmPolicy?: ( ctx: ChannelSecurityContext, diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 4d94afe49fd..a43dbb42876 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -345,6 +345,12 @@ export type ChannelThreadingToolContext = { export type ChannelMessagingAdapter = { normalizeTarget?: (raw: string) => string | undefined; + parseExplicitTarget?: (params: { raw: string }) => { + to: string; + threadId?: string | number; + chatType?: ChatType; + } | null; + inferTargetChatType?: (params: { to: string }) => ChatType | undefined; buildCrossContextComponents?: ChannelCrossContextComponentsFactory; enableInteractiveReplies?: (params: { cfg: OpenClawConfig; diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 713eff20bbe..6798545d22f 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -17,6 +17,7 @@ import type { ChannelSetupAdapter, ChannelStatusAdapter, ChannelAllowlistAdapter, + ChannelAcpBindingAdapter, } from "./types.adapters.js"; import type { ChannelAgentTool, @@ -77,6 +78,7 @@ export type ChannelPlugin { return { channel: "telegram", messageId: res.messageId, chatId: res.chatId }; }, }, + messaging: { + parseExplicitTarget: ({ raw }) => { + const target = parseTelegramTarget(raw); + return { + to: target.chatId, + threadId: target.messageThreadId, + chatType: target.chatType === "unknown" ? undefined : target.chatType, + }; + }, + inferTargetChatType: ({ to }) => { + const target = parseTelegramTarget(to); + return target.chatType === "unknown" ? undefined : target.chatType; + }, + }, }); telegramPlugin.config = { ...telegramPlugin.config, diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 9e10f525cb0..452875d9cff 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -1,8 +1,3 @@ -import { - markdownToSignalTextChunks, - type SignalTextStyleRange, -} from "../../../extensions/signal/src/format.js"; -import { sendMessageSignal } from "../../../extensions/signal/src/send.js"; import { chunkByParagraph, chunkMarkdownTextWithMode, @@ -10,14 +5,12 @@ import { resolveTextChunkLimit, } from "../../auto-reply/chunk.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; -import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js"; import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; import type { ChannelOutboundAdapter, ChannelOutboundContext, } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { appendAssistantMessageToSessionTranscript, resolveMirroredTranscriptText, @@ -51,7 +44,6 @@ export { normalizeOutboundPayloads } from "./payloads.js"; export { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js"; const log = createSubsystemLogger("outbound/deliver"); -const TELEGRAM_TEXT_LIMIT = 4096; export type OutboundDeliveryResult = { channel: Exclude; @@ -74,6 +66,9 @@ type ChannelHandler = { chunkerMode?: "text" | "markdown"; textChunkLimit?: number; supportsMedia: boolean; + normalizePayload?: (payload: ReplyPayload) => ReplyPayload | null; + shouldSkipPlainTextSanitization?: (payload: ReplyPayload) => boolean; + resolveEffectiveTextChunkLimit?: (fallbackLimit?: number) => number | undefined; sendPayload?: ( payload: ReplyPayload, overrides?: { @@ -81,6 +76,21 @@ type ChannelHandler = { threadId?: string | number | null; }, ) => Promise; + sendFormattedText?: ( + text: string, + overrides?: { + replyToId?: string | null; + threadId?: string | number | null; + }, + ) => Promise; + sendFormattedMedia?: ( + caption: string, + mediaUrl: string, + overrides?: { + replyToId?: string | null; + threadId?: string | number | null; + }, + ) => Promise; sendText: ( text: string, overrides?: { @@ -155,6 +165,20 @@ function createPluginHandler( chunkerMode, textChunkLimit: outbound.textChunkLimit, supportsMedia: Boolean(sendMedia), + normalizePayload: outbound.normalizePayload + ? (payload) => outbound.normalizePayload!({ payload }) + : undefined, + shouldSkipPlainTextSanitization: outbound.shouldSkipPlainTextSanitization + ? (payload) => outbound.shouldSkipPlainTextSanitization!({ payload }) + : undefined, + resolveEffectiveTextChunkLimit: outbound.resolveEffectiveTextChunkLimit + ? (fallbackLimit) => + outbound.resolveEffectiveTextChunkLimit!({ + cfg: params.cfg, + accountId: params.accountId ?? undefined, + fallbackLimit, + }) + : undefined, sendPayload: outbound.sendPayload ? async (payload, overrides) => outbound.sendPayload!({ @@ -164,6 +188,21 @@ function createPluginHandler( payload, }) : undefined, + sendFormattedText: outbound.sendFormattedText + ? async (text, overrides) => + outbound.sendFormattedText!({ + ...resolveCtx(overrides), + text, + }) + : undefined, + sendFormattedMedia: outbound.sendFormattedMedia + ? async (caption, mediaUrl, overrides) => + outbound.sendFormattedMedia!({ + ...resolveCtx(overrides), + text: caption, + mediaUrl, + }) + : undefined, sendText: async (text, overrides) => sendText({ ...resolveCtx(overrides), @@ -239,18 +278,13 @@ type MessageSentEvent = { messageId?: string; }; -function normalizePayloadForChannelDelivery( - payload: ReplyPayload, - channelId: string, -): ReplyPayload | null { +function normalizeEmptyPayloadForDelivery(payload: ReplyPayload): ReplyPayload | null { + const text = typeof payload.text === "string" ? payload.text : ""; const hasChannelData = hasReplyChannelData(payload.channelData); - const rawText = typeof payload.text === "string" ? payload.text : ""; - const normalizedText = - channelId === "whatsapp" ? rawText.replace(/^(?:[ \t]*\r?\n)+/, "") : rawText; - if (!normalizedText.trim()) { + if (!text.trim()) { if ( !hasReplyContent({ - text: normalizedText, + text, mediaUrl: payload.mediaUrl, mediaUrls: payload.mediaUrls, interactive: payload.interactive, @@ -259,26 +293,20 @@ function normalizePayloadForChannelDelivery( ) { return null; } - return { - ...payload, - text: "", - }; + if (text) { + return { + ...payload, + text: "", + }; + } } - if (normalizedText === rawText) { - return payload; - } - return { - ...payload, - text: normalizedText, - }; + return payload; } function normalizePayloadsForChannelDelivery( payloads: ReplyPayload[], channel: Exclude, - _cfg: OpenClawConfig, - _to: string, - _accountId?: string, + handler: ChannelHandler, ): ReplyPayload[] { const normalizedPayloads: ReplyPayload[] = []; for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { @@ -287,15 +315,19 @@ function normalizePayloadsForChannelDelivery( // Models occasionally produce
, , etc. that render as literal text. // See https://github.com/openclaw/openclaw/issues/31884 if (isPlainTextSurface(channel) && sanitizedPayload.text) { - // Telegram sendPayload uses textMode:"html". Preserve raw HTML in this path. - if (!(channel === "telegram" && sanitizedPayload.channelData)) { + if (!handler.shouldSkipPlainTextSanitization?.(sanitizedPayload)) { sanitizedPayload = { ...sanitizedPayload, text: sanitizeForPlainText(sanitizedPayload.text), }; } } - const normalized = normalizePayloadForChannelDelivery(sanitizedPayload, channel); + const normalizedPayload = handler.normalizePayload + ? handler.normalizePayload(sanitizedPayload) + : sanitizedPayload; + const normalized = normalizedPayload + ? normalizeEmptyPayloadForDelivery(normalizedPayload) + : null; if (normalized) { normalizedPayloads.push(normalized); } @@ -513,8 +545,6 @@ async function deliverOutboundPayloadsCore( const accountId = params.accountId; const deps = params.deps; const abortSignal = params.abortSignal; - const sendSignal = - resolveOutboundSendDep(params.deps, "signal") ?? sendMessageSignal; const mediaLocalRoots = getAgentScopedMediaLocalRoots( cfg, params.session?.agentId ?? params.mirror?.agentId, @@ -539,24 +569,10 @@ async function deliverOutboundPayloadsCore( fallbackLimit: handler.textChunkLimit, }) : undefined; - const textLimit = - channel === "telegram" && typeof configuredTextLimit === "number" - ? Math.min(configuredTextLimit, TELEGRAM_TEXT_LIMIT) - : configuredTextLimit; + const textLimit = handler.resolveEffectiveTextChunkLimit + ? handler.resolveEffectiveTextChunkLimit(configuredTextLimit) + : configuredTextLimit; const chunkMode = handler.chunker ? resolveChunkMode(cfg, channel, accountId) : "length"; - const isSignalChannel = channel === "signal"; - const signalTableMode = isSignalChannel - ? resolveMarkdownTableMode({ cfg, channel: "signal", accountId }) - : "code"; - const signalMaxBytes = isSignalChannel - ? resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.signal?.mediaMaxMb, - accountId, - }) - : undefined; const sendTextChunks = async ( text: string, @@ -595,66 +611,7 @@ async function deliverOutboundPayloadsCore( results.push(await handler.sendText(chunk, overrides)); } }; - - const sendSignalText = async (text: string, styles: SignalTextStyleRange[]) => { - throwIfAborted(abortSignal); - return { - channel: "signal" as const, - ...(await sendSignal(to, text, { - cfg, - maxBytes: signalMaxBytes, - accountId: accountId ?? undefined, - textMode: "plain", - textStyles: styles, - })), - }; - }; - - const sendSignalTextChunks = async (text: string) => { - throwIfAborted(abortSignal); - let signalChunks = - textLimit === undefined - ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { - tableMode: signalTableMode, - }) - : markdownToSignalTextChunks(text, textLimit, { tableMode: signalTableMode }); - if (signalChunks.length === 0 && text) { - signalChunks = [{ text, styles: [] }]; - } - for (const chunk of signalChunks) { - throwIfAborted(abortSignal); - results.push(await sendSignalText(chunk.text, chunk.styles)); - } - }; - - const sendSignalMedia = async (caption: string, mediaUrl: string) => { - throwIfAborted(abortSignal); - const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY, { - tableMode: signalTableMode, - })[0] ?? { - text: caption, - styles: [], - }; - return { - channel: "signal" as const, - ...(await sendSignal(to, formatted.text, { - cfg, - mediaUrl, - maxBytes: signalMaxBytes, - accountId: accountId ?? undefined, - textMode: "plain", - textStyles: formatted.styles, - mediaLocalRoots, - })), - }; - }; - const normalizedPayloads = normalizePayloadsForChannelDelivery( - payloads, - channel, - cfg, - to, - accountId, - ); + const normalizedPayloads = normalizePayloadsForChannelDelivery(payloads, channel, handler); const hookRunner = getGlobalHookRunner(); const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key; const mirrorIsGroup = params.mirror?.isGroup; @@ -724,8 +681,8 @@ async function deliverOutboundPayloadsCore( } if (payloadSummary.mediaUrls.length === 0) { const beforeCount = results.length; - if (isSignalChannel) { - await sendSignalTextChunks(payloadSummary.text); + if (handler.sendFormattedText) { + results.push(...(await handler.sendFormattedText(payloadSummary.text, sendOverrides))); } else { await sendTextChunks(payloadSummary.text, sendOverrides); } @@ -770,8 +727,8 @@ async function deliverOutboundPayloadsCore( throwIfAborted(abortSignal); const caption = first ? payloadSummary.text : ""; first = false; - if (isSignalChannel) { - const delivery = await sendSignalMedia(caption, url); + if (handler.sendFormattedMedia) { + const delivery = await handler.sendFormattedMedia(caption, url, sendOverrides); results.push(delivery); lastMessageId = delivery.messageId; } else { diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 4da860d083f..4d9645dc130 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js"; +import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js"; +import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import { resolveHeartbeatDeliveryTarget, resolveOutboundTarget, @@ -17,6 +21,76 @@ import { runResolveOutboundTargetCoreTests(); +const telegramMessaging = { + parseExplicitTarget: ({ raw }: { raw: string }) => { + const target = parseTelegramTarget(raw); + return { + to: target.chatId, + threadId: target.messageThreadId, + chatType: target.chatType === "unknown" ? undefined : target.chatType, + }; + }, + inferTargetChatType: ({ to }: { to: string }) => { + const target = parseTelegramTarget(to); + return target.chatType === "unknown" ? undefined : target.chatType; + }, +}; + +const whatsappMessaging = { + inferTargetChatType: ({ to }: { to: string }) => { + const normalized = normalizeWhatsAppTarget(to); + if (!normalized) { + return undefined; + } + return isWhatsAppGroupJid(normalized) ? ("group" as const) : ("direct" as const); + }, +}; + +const noopOutbound = (channel: "discord" | "imessage" | "slack"): ChannelOutboundAdapter => ({ + deliveryMode: "direct", + sendText: async () => ({ channel, messageId: `${channel}-msg` }), +}); + +beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + plugin: createOutboundTestPlugin({ id: "discord", outbound: noopOutbound("discord") }), + source: "test", + }, + { + pluginId: "imessage", + plugin: createOutboundTestPlugin({ id: "imessage", outbound: noopOutbound("imessage") }), + source: "test", + }, + { + pluginId: "slack", + plugin: createOutboundTestPlugin({ id: "slack", outbound: noopOutbound("slack") }), + source: "test", + }, + { + pluginId: "telegram", + plugin: createOutboundTestPlugin({ + id: "telegram", + outbound: telegramOutbound, + messaging: telegramMessaging, + }), + source: "test", + }, + { + pluginId: "whatsapp", + plugin: createOutboundTestPlugin({ + id: "whatsapp", + outbound: whatsappOutbound, + messaging: whatsappMessaging, + }), + source: "test", + }, + ]), + ); +}); + describe("resolveOutboundTarget defaultTo config fallback", () => { installResolveOutboundTargetPluginRegistryHooks(); const whatsappDefaultCfg: OpenClawConfig = { @@ -80,7 +154,11 @@ describe("resolveOutboundTarget defaultTo config fallback", () => { registry.channels.push({ pluginId: "telegram", - plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }), + plugin: createOutboundTestPlugin({ + id: "telegram", + outbound: telegramOutbound, + messaging: telegramMessaging, + }), source: "test", }); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 9859176abbf..3a584473b8c 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,9 +1,3 @@ -import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js"; -import { parseSlackTarget } from "../../../extensions/slack/src/targets.js"; -import { - parseTelegramTarget, - resolveTelegramTargetChatType, -} from "../../../extensions/telegram/src/targets.js"; import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; import { formatCliCommand } from "../../cli/command-format.js"; @@ -22,7 +16,6 @@ import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; -import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import { normalizeDeliverableOutboundChannel, resolveOutboundChannelPlugin, @@ -65,6 +58,26 @@ export type SessionDeliveryTarget = { lastThreadId?: string | number; }; +function parseExplicitTargetWithPlugin(params: { + channel?: DeliverableMessageChannel; + fallbackChannel?: DeliverableMessageChannel; + raw?: string; +}) { + const raw = params.raw?.trim(); + if (!raw) { + return null; + } + const provider = params.channel ?? params.fallbackChannel; + if (!provider) { + return null; + } + return ( + resolveOutboundChannelPlugin({ channel: provider })?.messaging?.parseExplicitTarget?.({ + raw, + }) ?? null + ); +} + export function resolveSessionDeliveryTarget(params: { entry?: SessionEntry; requestedChannel?: GatewayMessageChannel | "last"; @@ -124,22 +137,19 @@ export function resolveSessionDeliveryTarget(params: { channel = params.fallbackChannel; } - // Parse :topic:NNN from explicitTo (Telegram topic syntax). - // Only applies when we positively know the channel is Telegram. - // When channel is unknown, the downstream send path (resolveTelegramSession) - // handles :topic: parsing independently. - const isTelegramContext = channel === "telegram" || (!channel && lastChannel === "telegram"); let explicitTo = rawExplicitTo; - let parsedThreadId: number | undefined; - if (isTelegramContext && rawExplicitTo && rawExplicitTo.includes(":topic:")) { - const parsed = parseTelegramTarget(rawExplicitTo); - explicitTo = parsed.chatId; - parsedThreadId = parsed.messageThreadId; + const parsedExplicitTarget = parseExplicitTargetWithPlugin({ + channel, + fallbackChannel: !channel ? lastChannel : undefined, + raw: rawExplicitTo, + }); + if (parsedExplicitTarget?.to) { + explicitTo = parsedExplicitTarget.to; } const explicitThreadId = params.explicitThreadId != null && params.explicitThreadId !== "" ? params.explicitThreadId - : parsedThreadId; + : parsedExplicitTarget?.threadId; let to = explicitTo; if (!to && lastTo) { @@ -387,70 +397,6 @@ function buildNoHeartbeatDeliveryTarget(params: { }; } -function inferDiscordTargetChatType(to: string): ChatType | undefined { - try { - const target = parseDiscordTarget(to, { defaultKind: "channel" }); - if (!target) { - return undefined; - } - return target.kind === "user" ? "direct" : "channel"; - } catch { - return undefined; - } -} - -function inferSlackTargetChatType(to: string): ChatType | undefined { - const target = parseSlackTarget(to, { defaultKind: "channel" }); - if (!target) { - return undefined; - } - return target.kind === "user" ? "direct" : "channel"; -} - -function inferTelegramTargetChatType(to: string): ChatType | undefined { - const chatType = resolveTelegramTargetChatType(to); - return chatType === "unknown" ? undefined : chatType; -} - -function inferWhatsAppTargetChatType(to: string): ChatType | undefined { - const normalized = normalizeWhatsAppTarget(to); - if (!normalized) { - return undefined; - } - return isWhatsAppGroupJid(normalized) ? "group" : "direct"; -} - -function inferSignalTargetChatType(rawTo: string): ChatType | undefined { - let to = rawTo.trim(); - if (!to) { - return undefined; - } - if (/^signal:/i.test(to)) { - to = to.replace(/^signal:/i, "").trim(); - } - if (!to) { - return undefined; - } - const lower = to.toLowerCase(); - if (lower.startsWith("group:")) { - return "group"; - } - if (lower.startsWith("username:") || lower.startsWith("u:")) { - return "direct"; - } - return "direct"; -} - -const HEARTBEAT_TARGET_CHAT_TYPE_INFERERS: Partial< - Record ChatType | undefined> -> = { - discord: inferDiscordTargetChatType, - slack: inferSlackTargetChatType, - telegram: inferTelegramTargetChatType, - whatsapp: inferWhatsAppTargetChatType, - signal: inferSignalTargetChatType, -}; - function inferChatTypeFromTarget(params: { channel: DeliverableMessageChannel; to: string; @@ -469,7 +415,9 @@ function inferChatTypeFromTarget(params: { if (/^group:/i.test(to)) { return "group"; } - return HEARTBEAT_TARGET_CHAT_TYPE_INFERERS[params.channel]?.(to); + return resolveOutboundChannelPlugin({ + channel: params.channel, + })?.messaging?.inferTargetChatType?.({ to }); } function resolveHeartbeatDeliveryChatType(params: { diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 2af1191feba..4f52350f8fc 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -1,6 +1,7 @@ import type { ChannelCapabilities, ChannelId, + ChannelMessagingAdapter, ChannelOutboundAdapter, ChannelPlugin, } from "../channels/plugins/types.js"; @@ -96,6 +97,7 @@ export const createMSTeamsTestPlugin = (params?: { export const createOutboundTestPlugin = (params: { id: ChannelId; outbound: ChannelOutboundAdapter; + messaging?: ChannelMessagingAdapter; label?: string; docsPath?: string; capabilities?: ChannelCapabilities; @@ -108,4 +110,5 @@ export const createOutboundTestPlugin = (params: { config: { listAccountIds: () => [] }, }), outbound: params.outbound, + ...(params.messaging ? { messaging: params.messaging } : {}), }); From c7137270d170d10a9e27f3e5a6f6979dd24b0223 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:30:04 -0700 Subject: [PATCH 043/133] Security: split audit runtime surfaces --- src/security/audit-channel.collect.runtime.ts | 1 + src/security/audit-channel.runtime.ts | 8 +- src/security/audit.deep.runtime.ts | 4 + src/security/audit.nondeep.runtime.ts | 26 +++++ src/security/audit.ts | 94 +++++++++---------- 5 files changed, 81 insertions(+), 52 deletions(-) create mode 100644 src/security/audit-channel.collect.runtime.ts create mode 100644 src/security/audit.deep.runtime.ts create mode 100644 src/security/audit.nondeep.runtime.ts diff --git a/src/security/audit-channel.collect.runtime.ts b/src/security/audit-channel.collect.runtime.ts new file mode 100644 index 00000000000..6a33ff6a93a --- /dev/null +++ b/src/security/audit-channel.collect.runtime.ts @@ -0,0 +1 @@ +export { collectChannelSecurityFindings } from "./audit-channel.js"; diff --git a/src/security/audit-channel.runtime.ts b/src/security/audit-channel.runtime.ts index 147f686862a..71fa1cbea6c 100644 --- a/src/security/audit-channel.runtime.ts +++ b/src/security/audit-channel.runtime.ts @@ -1,9 +1,9 @@ -export { - isNumericTelegramUserId, - normalizeTelegramAllowFromEntry, -} from "../../extensions/telegram/src/allow-from.js"; export { readChannelAllowFromStore } from "../pairing/pairing-store.js"; export { isDiscordMutableAllowEntry, isZalouserMutableGroupEntry, } from "./mutable-allowlist-detectors.js"; +export { + isNumericTelegramUserId, + normalizeTelegramAllowFromEntry, +} from "../../extensions/telegram/src/allow-from.js"; diff --git a/src/security/audit.deep.runtime.ts b/src/security/audit.deep.runtime.ts new file mode 100644 index 00000000000..25662225d9c --- /dev/null +++ b/src/security/audit.deep.runtime.ts @@ -0,0 +1,4 @@ +export { + collectInstalledSkillsCodeSafetyFindings, + collectPluginsCodeSafetyFindings, +} from "./audit-extra.async.js"; diff --git a/src/security/audit.nondeep.runtime.ts b/src/security/audit.nondeep.runtime.ts new file mode 100644 index 00000000000..5a962bf8386 --- /dev/null +++ b/src/security/audit.nondeep.runtime.ts @@ -0,0 +1,26 @@ +export { + collectAttackSurfaceSummaryFindings, + collectExposureMatrixFindings, + collectGatewayHttpNoAuthFindings, + collectGatewayHttpSessionKeyOverrideFindings, + collectHooksHardeningFindings, + collectLikelyMultiUserSetupFindings, + collectMinimalProfileOverrideFindings, + collectModelHygieneFindings, + collectNodeDangerousAllowCommandFindings, + collectNodeDenyCommandPatternFindings, + collectSandboxDangerousConfigFindings, + collectSandboxDockerNoopFindings, + collectSecretsInConfigFindings, + collectSmallModelRiskFindings, + collectSyncedFolderFindings, +} from "./audit-extra.sync.js"; + +export { + collectSandboxBrowserHashLabelFindings, + collectIncludeFilePermFindings, + collectPluginsTrustFindings, + collectStateDeepFilesystemFindings, + collectWorkspaceSkillSymlinkEscapeFindings, + readConfigSnapshotForAudit, +} from "./audit-extra.async.js"; diff --git a/src/security/audit.ts b/src/security/audit.ts index 0b13ecc5531..4e3ef0a6920 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -21,32 +21,6 @@ import { } from "../infra/exec-safe-bin-runtime-policy.js"; import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; import { isBlockedHostnameOrIp, isPrivateNetworkAllowedByPolicy } from "../infra/net/ssrf.js"; -import { collectChannelSecurityFindings } from "./audit-channel.js"; -import { - collectAttackSurfaceSummaryFindings, - collectExposureMatrixFindings, - collectGatewayHttpNoAuthFindings, - collectGatewayHttpSessionKeyOverrideFindings, - collectHooksHardeningFindings, - collectIncludeFilePermFindings, - collectInstalledSkillsCodeSafetyFindings, - collectLikelyMultiUserSetupFindings, - collectSandboxBrowserHashLabelFindings, - collectMinimalProfileOverrideFindings, - collectModelHygieneFindings, - collectNodeDangerousAllowCommandFindings, - collectNodeDenyCommandPatternFindings, - collectSmallModelRiskFindings, - collectSandboxDangerousConfigFindings, - collectSandboxDockerNoopFindings, - collectPluginsTrustFindings, - collectSecretsInConfigFindings, - collectPluginsCodeSafetyFindings, - collectStateDeepFilesystemFindings, - collectSyncedFolderFindings, - collectWorkspaceSkillSymlinkEscapeFindings, - readConfigSnapshotForAudit, -} from "./audit-extra.js"; import { formatPermissionDetail, formatPermissionRemediation, @@ -138,12 +112,32 @@ type AuditExecutionContext = { }; let channelPluginsModulePromise: Promise | undefined; +let auditNonDeepModulePromise: Promise | undefined; +let auditDeepModulePromise: Promise | undefined; +let auditChannelModulePromise: + | Promise + | undefined; async function loadChannelPlugins() { channelPluginsModulePromise ??= import("../channels/plugins/index.js"); return await channelPluginsModulePromise; } +async function loadAuditNonDeepModule() { + auditNonDeepModulePromise ??= import("./audit.nondeep.runtime.js"); + return await auditNonDeepModulePromise; +} + +async function loadAuditDeepModule() { + auditDeepModulePromise ??= import("./audit.deep.runtime.js"); + return await auditDeepModulePromise; +} + +async function loadAuditChannelModule() { + auditChannelModulePromise ??= import("./audit-channel.collect.runtime.js"); + return await auditChannelModulePromise; +} + function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { let critical = 0; let warn = 0; @@ -1144,6 +1138,7 @@ async function createAuditExecutionContext( const deepTimeoutMs = Math.max(250, opts.deepTimeoutMs ?? 5000); const stateDir = opts.stateDir ?? resolveStateDir(env); const configPath = opts.configPath ?? resolveConfigPath(env, stateDir); + const { readConfigSnapshotForAudit } = await loadAuditNonDeepModule(); const configSnapshot = includeFilesystem ? opts.configSnapshot !== undefined ? opts.configSnapshot @@ -1174,28 +1169,29 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise Date: Sun, 15 Mar 2026 23:30:10 -0700 Subject: [PATCH 044/133] Tests: add channel actions contract helper --- src/test-utils/channel-actions-contract.ts | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/test-utils/channel-actions-contract.ts diff --git a/src/test-utils/channel-actions-contract.ts b/src/test-utils/channel-actions-contract.ts new file mode 100644 index 00000000000..12d7d7046f5 --- /dev/null +++ b/src/test-utils/channel-actions-contract.ts @@ -0,0 +1,53 @@ +import { expect, it } from "vitest"; +import type { ChannelMessageCapability } from "../channels/plugins/message-capabilities.js"; +import type { ChannelMessageActionName, ChannelPlugin } from "../channels/plugins/types.js"; +import type { OpenClawConfig } from "../config/config.js"; + +type ChannelActionsContractCase = { + name: string; + cfg: OpenClawConfig; + expectedActions: readonly ChannelMessageActionName[]; + expectedCapabilities?: readonly ChannelMessageCapability[]; + beforeTest?: () => void; +}; + +export function installChannelActionsContractSuite(params: { + plugin: Pick; + cases: readonly ChannelActionsContractCase[]; + unsupportedAction?: ChannelMessageActionName; +}) { + it("exposes the base message actions contract", () => { + expect(params.plugin.actions).toBeDefined(); + expect(typeof params.plugin.actions?.listActions).toBe("function"); + }); + + for (const testCase of params.cases) { + it(`actions contract: ${testCase.name}`, () => { + testCase.beforeTest?.(); + + const actions = params.plugin.actions?.listActions?.({ cfg: testCase.cfg }) ?? []; + const capabilities = params.plugin.actions?.getCapabilities?.({ cfg: testCase.cfg }) ?? []; + + expect(actions).toEqual([...new Set(actions)]); + expect(capabilities).toEqual([...new Set(capabilities)]); + expect(actions.toSorted()).toEqual([...testCase.expectedActions].toSorted()); + expect(capabilities.toSorted()).toEqual( + [...(testCase.expectedCapabilities ?? [])].toSorted(), + ); + + if (params.plugin.actions?.supportsAction) { + for (const action of testCase.expectedActions) { + expect(params.plugin.actions.supportsAction({ action })).toBe(true); + } + if ( + params.unsupportedAction && + !testCase.expectedActions.includes(params.unsupportedAction) + ) { + expect(params.plugin.actions.supportsAction({ action: params.unsupportedAction })).toBe( + false, + ); + } + } + }); + } +} From c01515672f6b62ccddffda6b40d4912cc6eb2e1a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:30:42 -0700 Subject: [PATCH 045/133] Tests: add channel plugin contract helper --- src/test-utils/channel-plugin-contract.ts | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/test-utils/channel-plugin-contract.ts diff --git a/src/test-utils/channel-plugin-contract.ts b/src/test-utils/channel-plugin-contract.ts new file mode 100644 index 00000000000..b2912befd0b --- /dev/null +++ b/src/test-utils/channel-plugin-contract.ts @@ -0,0 +1,24 @@ +import { expect, it } from "vitest"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; + +export function installChannelPluginContractSuite(params: { + plugin: Pick; +}) { + it("satisfies the base channel plugin contract", () => { + const { plugin } = params; + + expect(typeof plugin.id).toBe("string"); + expect(plugin.id.trim()).not.toBe(""); + + expect(plugin.meta.id).toBe(plugin.id); + expect(plugin.meta.label.trim()).not.toBe(""); + expect(plugin.meta.selectionLabel.trim()).not.toBe(""); + expect(plugin.meta.docsPath).toMatch(/^\/channels\//); + expect(plugin.meta.blurb.trim()).not.toBe(""); + + expect(plugin.capabilities.chatTypes.length).toBeGreaterThan(0); + + expect(typeof plugin.config.listAccountIds).toBe("function"); + expect(typeof plugin.config.resolveAccount).toBe("function"); + }); +} From 4ae80407a680e8007a4c7e15b7391806256a80e1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:31:22 -0700 Subject: [PATCH 046/133] Tests: add Slack channel contract suite --- extensions/slack/src/channel.contract.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 extensions/slack/src/channel.contract.test.ts diff --git a/extensions/slack/src/channel.contract.test.ts b/extensions/slack/src/channel.contract.test.ts new file mode 100644 index 00000000000..3fd0e23dab5 --- /dev/null +++ b/extensions/slack/src/channel.contract.test.ts @@ -0,0 +1,85 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/slack"; +import { describe } from "vitest"; +import { installChannelActionsContractSuite } from "../../../src/test-utils/channel-actions-contract.js"; +import { installChannelPluginContractSuite } from "../../../src/test-utils/channel-plugin-contract.js"; +import { slackPlugin } from "./channel.js"; + +describe("slackPlugin contract", () => { + installChannelPluginContractSuite({ + plugin: slackPlugin, + }); + + installChannelActionsContractSuite({ + plugin: slackPlugin, + unsupportedAction: "poll", + cases: [ + { + name: "configured account exposes default Slack actions", + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, + } as OpenClawConfig, + expectedActions: [ + "send", + "react", + "reactions", + "read", + "edit", + "delete", + "download-file", + "pin", + "unpin", + "list-pins", + "member-info", + "emoji-list", + ], + expectedCapabilities: ["blocks"], + }, + { + name: "interactive replies add the shared interactive capability", + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + capabilities: { + interactiveReplies: true, + }, + }, + }, + } as OpenClawConfig, + expectedActions: [ + "send", + "react", + "reactions", + "read", + "edit", + "delete", + "download-file", + "pin", + "unpin", + "list-pins", + "member-info", + "emoji-list", + ], + expectedCapabilities: ["blocks", "interactive"], + }, + { + name: "missing tokens disables the actions surface", + cfg: { + channels: { + slack: { + enabled: true, + }, + }, + } as OpenClawConfig, + expectedActions: [], + expectedCapabilities: [], + }, + ], + }); +}); From 13090da3ac3496512ee0ea4220950fa48bd56a17 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:31:29 -0700 Subject: [PATCH 047/133] Tests: add Mattermost channel contract suite --- .../mattermost/src/channel.contract.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 extensions/mattermost/src/channel.contract.test.ts diff --git a/extensions/mattermost/src/channel.contract.test.ts b/extensions/mattermost/src/channel.contract.test.ts new file mode 100644 index 00000000000..96f5fe9ed4a --- /dev/null +++ b/extensions/mattermost/src/channel.contract.test.ts @@ -0,0 +1,59 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { describe } from "vitest"; +import { installChannelActionsContractSuite } from "../../../src/test-utils/channel-actions-contract.js"; +import { installChannelPluginContractSuite } from "../../../src/test-utils/channel-plugin-contract.js"; +import { mattermostPlugin } from "./channel.js"; + +describe("mattermostPlugin contract", () => { + installChannelPluginContractSuite({ + plugin: mattermostPlugin, + }); + + installChannelActionsContractSuite({ + plugin: mattermostPlugin, + unsupportedAction: "poll", + cases: [ + { + name: "configured account exposes send and react", + cfg: { + channels: { + mattermost: { + enabled: true, + botToken: "test-token", + baseUrl: "https://chat.example.com", + }, + }, + } as OpenClawConfig, + expectedActions: ["send", "react"], + expectedCapabilities: ["buttons"], + }, + { + name: "reactions can be disabled while send stays available", + cfg: { + channels: { + mattermost: { + enabled: true, + botToken: "test-token", + baseUrl: "https://chat.example.com", + actions: { reactions: false }, + }, + }, + } as OpenClawConfig, + expectedActions: ["send"], + expectedCapabilities: ["buttons"], + }, + { + name: "missing bot credentials disables the actions surface", + cfg: { + channels: { + mattermost: { + enabled: true, + }, + }, + } as OpenClawConfig, + expectedActions: [], + expectedCapabilities: [], + }, + ], + }); +}); From 4fc3492da516998c63b09ccee534090084ea326a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:31:34 -0700 Subject: [PATCH 048/133] Tests: add Telegram channel contract suite --- .../telegram/src/channel.contract.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 extensions/telegram/src/channel.contract.test.ts diff --git a/extensions/telegram/src/channel.contract.test.ts b/extensions/telegram/src/channel.contract.test.ts new file mode 100644 index 00000000000..164f862949d --- /dev/null +++ b/extensions/telegram/src/channel.contract.test.ts @@ -0,0 +1,49 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/telegram"; +import { afterEach, describe, vi } from "vitest"; +import { installChannelActionsContractSuite } from "../../../src/test-utils/channel-actions-contract.js"; +import { installChannelPluginContractSuite } from "../../../src/test-utils/channel-plugin-contract.js"; + +const telegramListActionsMock = vi.fn(); +const telegramGetCapabilitiesMock = vi.fn(); + +vi.mock("./runtime.js", () => ({ + getTelegramRuntime: () => ({ + channel: { + telegram: { + messageActions: { + listActions: telegramListActionsMock, + getCapabilities: telegramGetCapabilitiesMock, + }, + }, + }, + }), +})); + +const { telegramPlugin } = await import("./channel.js"); + +describe("telegramPlugin contract", () => { + afterEach(() => { + telegramListActionsMock.mockReset(); + telegramGetCapabilitiesMock.mockReset(); + }); + + installChannelPluginContractSuite({ + plugin: telegramPlugin, + }); + + installChannelActionsContractSuite({ + plugin: telegramPlugin, + cases: [ + { + name: "forwards runtime-backed Telegram actions and capabilities", + cfg: {} as OpenClawConfig, + expectedActions: ["send", "poll", "react"], + expectedCapabilities: ["interactive", "buttons"], + beforeTest: () => { + telegramListActionsMock.mockReturnValue(["send", "poll", "react"]); + telegramGetCapabilitiesMock.mockReturnValue(["interactive", "buttons"]); + }, + }, + ], + }); +}); From 3838ef9b2a836792566fae1122a75e3b77545db5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:31:39 -0700 Subject: [PATCH 049/133] Tests: add Discord channel contract suite --- .../discord/src/channel.contract.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 extensions/discord/src/channel.contract.test.ts diff --git a/extensions/discord/src/channel.contract.test.ts b/extensions/discord/src/channel.contract.test.ts new file mode 100644 index 00000000000..3a651e6e26b --- /dev/null +++ b/extensions/discord/src/channel.contract.test.ts @@ -0,0 +1,49 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/discord"; +import { afterEach, describe, vi } from "vitest"; +import { installChannelActionsContractSuite } from "../../../src/test-utils/channel-actions-contract.js"; +import { installChannelPluginContractSuite } from "../../../src/test-utils/channel-plugin-contract.js"; + +const discordListActionsMock = vi.fn(); +const discordGetCapabilitiesMock = vi.fn(); + +vi.mock("./runtime.js", () => ({ + getDiscordRuntime: () => ({ + channel: { + discord: { + messageActions: { + listActions: discordListActionsMock, + getCapabilities: discordGetCapabilitiesMock, + }, + }, + }, + }), +})); + +const { discordPlugin } = await import("./channel.js"); + +describe("discordPlugin contract", () => { + afterEach(() => { + discordListActionsMock.mockReset(); + discordGetCapabilitiesMock.mockReset(); + }); + + installChannelPluginContractSuite({ + plugin: discordPlugin, + }); + + installChannelActionsContractSuite({ + plugin: discordPlugin, + cases: [ + { + name: "forwards runtime-backed Discord actions and capabilities", + cfg: {} as OpenClawConfig, + expectedActions: ["send", "react", "poll"], + expectedCapabilities: ["interactive", "components"], + beforeTest: () => { + discordListActionsMock.mockReturnValue(["send", "react", "poll"]); + discordGetCapabilitiesMock.mockReturnValue(["interactive", "components"]); + }, + }, + ], + }); +}); From 623ba1403108b0330b740c9765e90cfe85dd203a Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Mon, 16 Mar 2026 02:18:02 +0000 Subject: [PATCH 050/133] fix(session): preserve external channel route when webchat views session (#47745) When a Telegram/WhatsApp/iMessage session was viewed or messaged from the dashboard/webchat, resolveLastChannelRaw() unconditionally returned 'webchat' for any isDirectSessionKey() or isMainSessionKey() match, overwriting the persisted external delivery route. This caused subagent completion events to be delivered to the webchat/dashboard instead of the original channel (Telegram, WhatsApp, etc.), silently dropping messages for the channel user. Fix: only allow webchat to own routing when no external delivery route has been established (no persisted external lastChannel, no external channel hint in the session key). If an external route exists, webchat is treated as admin/monitoring access and must not mutate the delivery route. Updated/added tests to document the correct behaviour. Fixes #47745 --- src/auto-reply/reply/session-delivery.test.ts | 41 +++++++----- src/auto-reply/reply/session-delivery.ts | 26 ++++++-- src/auto-reply/reply/session.test.ts | 65 +++++++++++++++---- 3 files changed, 96 insertions(+), 36 deletions(-) diff --git a/src/auto-reply/reply/session-delivery.test.ts b/src/auto-reply/reply/session-delivery.test.ts index 2bfb4812f64..bd5f0d7c27b 100644 --- a/src/auto-reply/reply/session-delivery.test.ts +++ b/src/auto-reply/reply/session-delivery.test.ts @@ -9,24 +9,29 @@ describe("session delivery direct-session routing overrides", () => { "agent:main:telegram:dm:123456", "agent:main:telegram:direct:123456:thread:99", "agent:main:telegram:account-a:direct:123456:topic:ops", - ])("lets webchat override persisted routes for strict direct key %s", (sessionKey) => { - expect( - resolveLastChannelRaw({ - originatingChannelRaw: "webchat", - persistedLastChannel: "telegram", - sessionKey, - }), - ).toBe("webchat"); - expect( - resolveLastToRaw({ - originatingChannelRaw: "webchat", - originatingToRaw: "session:dashboard", - persistedLastChannel: "telegram", - persistedLastTo: "123456", - sessionKey, - }), - ).toBe("session:dashboard"); - }); + ])( + "preserves persisted external route when webchat accesses channel-peer session %s (fixes #47745)", + (sessionKey) => { + // Webchat/dashboard viewing an external-channel session must not overwrite + // the delivery route — subagents must still deliver to the original channel. + expect( + resolveLastChannelRaw({ + originatingChannelRaw: "webchat", + persistedLastChannel: "telegram", + sessionKey, + }), + ).toBe("telegram"); + expect( + resolveLastToRaw({ + originatingChannelRaw: "webchat", + originatingToRaw: "session:dashboard", + persistedLastChannel: "telegram", + persistedLastTo: "123456", + sessionKey, + }), + ).toBe("123456"); + }, + ); it.each([ "agent:main:main:direct", diff --git a/src/auto-reply/reply/session-delivery.ts b/src/auto-reply/reply/session-delivery.ts index ef2f0cde227..1197b7c5245 100644 --- a/src/auto-reply/reply/session-delivery.ts +++ b/src/auto-reply/reply/session-delivery.ts @@ -90,16 +90,25 @@ export function resolveLastChannelRaw(params: { sessionKey?: string; }): string | undefined { const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw); - // WebChat should own reply routing for direct-session UI turns, even when the - // session previously replied through an external channel like iMessage. + // WebChat should own reply routing for direct-session UI turns, but only when + // the session has no established external delivery route. If the session was + // created via an external channel (e.g. Telegram, iMessage), webchat/dashboard + // access must not overwrite the persisted route — doing so causes subagent + // completion events to be delivered to the dashboard instead of the original + // channel. See: https://github.com/openclaw/openclaw/issues/47745 + const persistedChannel = normalizeMessageChannel(params.persistedLastChannel); + const sessionKeyChannelHintForCheck = resolveSessionKeyChannelHint(params.sessionKey); + const hasEstablishedExternalRoute = + isExternalRoutingChannel(persistedChannel) || + isExternalRoutingChannel(sessionKeyChannelHintForCheck); if ( originatingChannel === INTERNAL_MESSAGE_CHANNEL && + !hasEstablishedExternalRoute && (isMainSessionKey(params.sessionKey) || isDirectSessionKey(params.sessionKey)) ) { return params.originatingChannelRaw; } - const persistedChannel = normalizeMessageChannel(params.persistedLastChannel); - const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey); + const sessionKeyChannelHint = sessionKeyChannelHintForCheck; let resolved = params.originatingChannelRaw || params.persistedLastChannel; // Internal/non-deliverable sources should not overwrite previously known // external delivery routes (or explicit channel hints from the session key). @@ -122,14 +131,19 @@ export function resolveLastToRaw(params: { sessionKey?: string; }): string | undefined { const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw); + const persistedChannel = normalizeMessageChannel(params.persistedLastChannel); + const sessionKeyChannelHintForToCheck = resolveSessionKeyChannelHint(params.sessionKey); + const hasEstablishedExternalRouteForTo = + isExternalRoutingChannel(persistedChannel) || + isExternalRoutingChannel(sessionKeyChannelHintForToCheck); if ( originatingChannel === INTERNAL_MESSAGE_CHANNEL && + !hasEstablishedExternalRouteForTo && (isMainSessionKey(params.sessionKey) || isDirectSessionKey(params.sessionKey)) ) { return params.originatingToRaw || params.toRaw; } - const persistedChannel = normalizeMessageChannel(params.persistedLastChannel); - const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey); + const sessionKeyChannelHint = sessionKeyChannelHintForToCheck; // When the turn originates from an internal/non-deliverable source, do not // replace an established external destination with internal routing ids diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index db0870b704a..5c382a74aa9 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1942,8 +1942,11 @@ describe("initSessionState internal channel routing preservation", () => { expect(result.sessionEntry.deliveryContext?.to).toBe("group:12345"); }); - it("lets direct webchat turns override persisted external routes for per-channel-peer sessions", async () => { - const storePath = await createStorePath("webchat-direct-route-override-"); + it("preserves persisted external route when webchat views a channel-peer session (fixes #47745)", async () => { + // Regression: dashboard/webchat access must not overwrite an established + // external delivery route (e.g. Telegram/iMessage) on a channel-scoped session. + // Subagent completions should still be delivered to the original channel. + const storePath = await createStorePath("webchat-direct-route-preserve-"); const sessionKey = "agent:main:imessage:direct:+1555"; await writeSessionStoreFast(storePath, { [sessionKey]: { @@ -1973,6 +1976,40 @@ describe("initSessionState internal channel routing preservation", () => { commandAuthorized: true, }); + // External route must be preserved — webchat is admin/monitoring only + expect(result.sessionEntry.lastChannel).toBe("imessage"); + expect(result.sessionEntry.lastTo).toBe("+1555"); + expect(result.sessionEntry.deliveryContext?.channel).toBe("imessage"); + expect(result.sessionEntry.deliveryContext?.to).toBe("+1555"); + }); + + it("lets direct webchat turns own routing for sessions with no prior external route", async () => { + // Webchat should still own routing for sessions that were created via webchat + // (no external channel ever established). + const storePath = await createStorePath("webchat-direct-route-noext-"); + const sessionKey = "agent:main:main"; + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: "sess-webchat-noext", + updatedAt: Date.now(), + }, + }); + const cfg = { + session: { store: storePath, dmScope: "per-channel-peer" }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "reply from control ui", + SessionKey: sessionKey, + OriginatingChannel: "webchat", + OriginatingTo: "session:dashboard", + Surface: "webchat", + }, + cfg, + commandAuthorized: true, + }); + expect(result.sessionEntry.lastChannel).toBe("webchat"); expect(result.sessionEntry.lastTo).toBe("session:dashboard"); expect(result.sessionEntry.deliveryContext?.channel).toBe("webchat"); @@ -2068,8 +2105,10 @@ describe("initSessionState internal channel routing preservation", () => { expect(result.sessionEntry.lastChannel).toBe("webchat"); }); - it("does not reuse stale external lastTo for webchat/main turns without destination", async () => { - const storePath = await createStorePath("webchat-main-no-stale-lastto-"); + it("preserves external route for main session when webchat accesses without destination (fixes #47745)", async () => { + // Regression: webchat monitoring a main session that has an established WhatsApp + // route must not clear that route. Subagents should still deliver to WhatsApp. + const storePath = await createStorePath("webchat-main-preserve-external-"); const sessionKey = "agent:main:main"; await writeSessionStoreFast(storePath, { [sessionKey]: { @@ -2095,12 +2134,14 @@ describe("initSessionState internal channel routing preservation", () => { commandAuthorized: true, }); - expect(result.sessionEntry.lastChannel).toBe("webchat"); - expect(result.sessionEntry.lastTo).toBeUndefined(); + expect(result.sessionEntry.lastChannel).toBe("whatsapp"); + expect(result.sessionEntry.lastTo).toBe("+15555550123"); }); - it("prefers webchat route over persisted external route for main session turns", async () => { - const storePath = await createStorePath("prefer-webchat-main-route-"); + it("preserves external route for main session when webchat sends with destination (fixes #47745)", async () => { + // Regression: webchat sending to a main session with an established WhatsApp route + // must not steal that route for webchat delivery. + const storePath = await createStorePath("preserve-main-external-webchat-send-"); const sessionKey = "agent:main:main"; await writeSessionStoreFast(storePath, { [sessionKey]: { @@ -2127,9 +2168,9 @@ describe("initSessionState internal channel routing preservation", () => { commandAuthorized: true, }); - expect(result.sessionEntry.lastChannel).toBe("webchat"); - expect(result.sessionEntry.lastTo).toBe("session:webchat-main"); - expect(result.sessionEntry.deliveryContext?.channel).toBe("webchat"); - expect(result.sessionEntry.deliveryContext?.to).toBe("session:webchat-main"); + expect(result.sessionEntry.lastChannel).toBe("whatsapp"); + expect(result.sessionEntry.lastTo).toBe("+15555550123"); + expect(result.sessionEntry.deliveryContext?.channel).toBe("whatsapp"); + expect(result.sessionEntry.deliveryContext?.to).toBe("+15555550123"); }); }); From fb47777d38814533ff30319faf49990923e81886 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 06:37:09 +0000 Subject: [PATCH 051/133] fix: address bot nit on session route preservation (#47797) (thanks @brokemac79) --- CHANGELOG.md | 1 + src/auto-reply/reply/session-delivery.ts | 13 ++++--------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddfb252fc71..a21ff47279b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai - Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras. - Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc. - Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI. +- Control UI/session routing: preserve established external delivery routes when webchat views or sends in externally originated sessions, so subagent completions still return to the original channel instead of the dashboard. (#47797) Thanks @brokemac79. ## 2026.3.13 diff --git a/src/auto-reply/reply/session-delivery.ts b/src/auto-reply/reply/session-delivery.ts index 1197b7c5245..9a112a6829e 100644 --- a/src/auto-reply/reply/session-delivery.ts +++ b/src/auto-reply/reply/session-delivery.ts @@ -97,10 +97,9 @@ export function resolveLastChannelRaw(params: { // completion events to be delivered to the dashboard instead of the original // channel. See: https://github.com/openclaw/openclaw/issues/47745 const persistedChannel = normalizeMessageChannel(params.persistedLastChannel); - const sessionKeyChannelHintForCheck = resolveSessionKeyChannelHint(params.sessionKey); + const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey); const hasEstablishedExternalRoute = - isExternalRoutingChannel(persistedChannel) || - isExternalRoutingChannel(sessionKeyChannelHintForCheck); + isExternalRoutingChannel(persistedChannel) || isExternalRoutingChannel(sessionKeyChannelHint); if ( originatingChannel === INTERNAL_MESSAGE_CHANNEL && !hasEstablishedExternalRoute && @@ -108,7 +107,6 @@ export function resolveLastChannelRaw(params: { ) { return params.originatingChannelRaw; } - const sessionKeyChannelHint = sessionKeyChannelHintForCheck; let resolved = params.originatingChannelRaw || params.persistedLastChannel; // Internal/non-deliverable sources should not overwrite previously known // external delivery routes (or explicit channel hints from the session key). @@ -132,10 +130,9 @@ export function resolveLastToRaw(params: { }): string | undefined { const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw); const persistedChannel = normalizeMessageChannel(params.persistedLastChannel); - const sessionKeyChannelHintForToCheck = resolveSessionKeyChannelHint(params.sessionKey); + const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey); const hasEstablishedExternalRouteForTo = - isExternalRoutingChannel(persistedChannel) || - isExternalRoutingChannel(sessionKeyChannelHintForToCheck); + isExternalRoutingChannel(persistedChannel) || isExternalRoutingChannel(sessionKeyChannelHint); if ( originatingChannel === INTERNAL_MESSAGE_CHANNEL && !hasEstablishedExternalRouteForTo && @@ -143,8 +140,6 @@ export function resolveLastToRaw(params: { ) { return params.originatingToRaw || params.toRaw; } - const sessionKeyChannelHint = sessionKeyChannelHintForToCheck; - // When the turn originates from an internal/non-deliverable source, do not // replace an established external destination with internal routing ids // (e.g., session/webchat ids). From 3105a1284a872f1d80c36b489c550f2e323e0898 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:45:06 -0700 Subject: [PATCH 052/133] Tests: add plugin contract suites --- src/channels/plugins/contracts/suites.ts | 214 +++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 src/channels/plugins/contracts/suites.ts diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts new file mode 100644 index 00000000000..48a0f886208 --- /dev/null +++ b/src/channels/plugins/contracts/suites.ts @@ -0,0 +1,214 @@ +import { expect, it } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; +import type { + ChannelAccountSnapshot, + ChannelAccountState, + ChannelMessageCapability, + ChannelSetupInput, +} from "../types.core.js"; +import type { ChannelPlugin } from "../types.js"; +import type { ChannelMessageActionName } from "../types.js"; + +function sortStrings(values: readonly string[]) { + return [...values].toSorted((left, right) => left.localeCompare(right)); +} + +export function installChannelPluginContractSuite(params: { + plugin: Pick; +}) { + it("satisfies the base channel plugin contract", () => { + const { plugin } = params; + + expect(typeof plugin.id).toBe("string"); + expect(plugin.id.trim()).not.toBe(""); + + expect(plugin.meta.id).toBe(plugin.id); + expect(plugin.meta.label.trim()).not.toBe(""); + expect(plugin.meta.selectionLabel.trim()).not.toBe(""); + expect(plugin.meta.docsPath).toMatch(/^\/channels\//); + expect(plugin.meta.blurb.trim()).not.toBe(""); + + expect(plugin.capabilities.chatTypes.length).toBeGreaterThan(0); + + expect(typeof plugin.config.listAccountIds).toBe("function"); + expect(typeof plugin.config.resolveAccount).toBe("function"); + }); +} + +type ChannelActionsContractCase = { + name: string; + cfg: OpenClawConfig; + expectedActions: readonly ChannelMessageActionName[]; + expectedCapabilities?: readonly ChannelMessageCapability[]; + beforeTest?: () => void; +}; + +export function installChannelActionsContractSuite(params: { + plugin: Pick; + cases: readonly ChannelActionsContractCase[]; + unsupportedAction?: ChannelMessageActionName; +}) { + it("exposes the base message actions contract", () => { + expect(params.plugin.actions).toBeDefined(); + expect(typeof params.plugin.actions?.listActions).toBe("function"); + }); + + for (const testCase of params.cases) { + it(`actions contract: ${testCase.name}`, () => { + testCase.beforeTest?.(); + + const actions = params.plugin.actions?.listActions?.({ cfg: testCase.cfg }) ?? []; + const capabilities = params.plugin.actions?.getCapabilities?.({ cfg: testCase.cfg }) ?? []; + + expect(actions).toEqual([...new Set(actions)]); + expect(capabilities).toEqual([...new Set(capabilities)]); + expect(sortStrings(actions)).toEqual(sortStrings(testCase.expectedActions)); + expect(sortStrings(capabilities)).toEqual(sortStrings(testCase.expectedCapabilities ?? [])); + + if (params.plugin.actions?.supportsAction) { + for (const action of testCase.expectedActions) { + expect(params.plugin.actions.supportsAction({ action })).toBe(true); + } + if ( + params.unsupportedAction && + !testCase.expectedActions.includes(params.unsupportedAction) + ) { + expect(params.plugin.actions.supportsAction({ action: params.unsupportedAction })).toBe( + false, + ); + } + } + }); + } +} + +type ChannelSetupContractCase = { + name: string; + cfg: OpenClawConfig; + accountId?: string; + input: ChannelSetupInput; + expectedAccountId?: string; + expectedValidation?: string | null; + beforeTest?: () => void; + assertPatchedConfig?: (cfg: OpenClawConfig) => void; + assertResolvedAccount?: (account: ResolvedAccount, cfg: OpenClawConfig) => void; +}; + +export function installChannelSetupContractSuite(params: { + plugin: Pick, "id" | "config" | "setup">; + cases: readonly ChannelSetupContractCase[]; +}) { + it("exposes the base setup contract", () => { + expect(params.plugin.setup).toBeDefined(); + expect(typeof params.plugin.setup?.applyAccountConfig).toBe("function"); + }); + + for (const testCase of params.cases) { + it(`setup contract: ${testCase.name}`, () => { + testCase.beforeTest?.(); + + const resolvedAccountId = + params.plugin.setup?.resolveAccountId?.({ + cfg: testCase.cfg, + accountId: testCase.accountId, + input: testCase.input, + }) ?? + testCase.accountId ?? + "default"; + + expect(resolvedAccountId).toBe(testCase.expectedAccountId ?? resolvedAccountId); + + const validation = + params.plugin.setup?.validateInput?.({ + cfg: testCase.cfg, + accountId: resolvedAccountId, + input: testCase.input, + }) ?? null; + expect(validation).toBe(testCase.expectedValidation ?? null); + + const nextCfg = params.plugin.setup?.applyAccountConfig({ + cfg: testCase.cfg, + accountId: resolvedAccountId, + input: testCase.input, + }); + expect(nextCfg).toBeDefined(); + + const account = params.plugin.config.resolveAccount(nextCfg!, resolvedAccountId); + testCase.assertPatchedConfig?.(nextCfg!); + testCase.assertResolvedAccount?.(account, nextCfg!); + }); + } +} + +type ChannelStatusContractCase = { + name: string; + cfg: OpenClawConfig; + accountId?: string; + runtime?: ChannelAccountSnapshot; + probe?: Probe; + beforeTest?: () => void; + expectedState?: ChannelAccountState; + resolveStateInput?: { + configured: boolean; + enabled: boolean; + }; + assertSnapshot?: (snapshot: ChannelAccountSnapshot) => void; + assertSummary?: (summary: Record) => void; +}; + +export function installChannelStatusContractSuite(params: { + plugin: Pick, "id" | "config" | "status">; + cases: readonly ChannelStatusContractCase[]; +}) { + it("exposes the base status contract", () => { + expect(params.plugin.status).toBeDefined(); + expect(typeof params.plugin.status?.buildAccountSnapshot).toBe("function"); + }); + + if (params.plugin.status?.defaultRuntime) { + it("status contract: default runtime is shaped like an account snapshot", () => { + expect(typeof params.plugin.status?.defaultRuntime?.accountId).toBe("string"); + }); + } + + for (const testCase of params.cases) { + it(`status contract: ${testCase.name}`, async () => { + testCase.beforeTest?.(); + + const account = params.plugin.config.resolveAccount(testCase.cfg, testCase.accountId); + const snapshot = await params.plugin.status!.buildAccountSnapshot!({ + account, + cfg: testCase.cfg, + runtime: testCase.runtime, + probe: testCase.probe, + }); + + expect(typeof snapshot.accountId).toBe("string"); + expect(snapshot.accountId.trim()).not.toBe(""); + testCase.assertSnapshot?.(snapshot); + + if (params.plugin.status?.buildChannelSummary) { + const defaultAccountId = + params.plugin.config.defaultAccountId?.(testCase.cfg) ?? testCase.accountId ?? "default"; + const summary = await params.plugin.status.buildChannelSummary({ + account, + cfg: testCase.cfg, + defaultAccountId, + snapshot, + }); + expect(summary).toEqual(expect.any(Object)); + testCase.assertSummary?.(summary); + } + + if (testCase.expectedState && params.plugin.status?.resolveAccountState) { + const state = params.plugin.status.resolveAccountState({ + account, + cfg: testCase.cfg, + configured: testCase.resolveStateInput?.configured ?? true, + enabled: testCase.resolveStateInput?.enabled ?? true, + }); + expect(state).toBe(testCase.expectedState); + } + }); + } +} From 6043e733a653bdb6ec1aadc39aaf73a258b52b16 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:45:14 -0700 Subject: [PATCH 053/133] Tests: add plugin contract registry --- src/channels/plugins/contracts/registry.ts | 485 +++++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 src/channels/plugins/contracts/registry.ts diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts new file mode 100644 index 00000000000..f5bb1d845e2 --- /dev/null +++ b/src/channels/plugins/contracts/registry.ts @@ -0,0 +1,485 @@ +import { expect, vi } from "vitest"; +import { bluebubblesPlugin } from "../../../../extensions/bluebubbles/src/channel.js"; +import { discordPlugin } from "../../../../extensions/discord/src/channel.js"; +import { setDiscordRuntime } from "../../../../extensions/discord/src/runtime.js"; +import { feishuPlugin } from "../../../../extensions/feishu/src/channel.js"; +import { googlechatPlugin } from "../../../../extensions/googlechat/src/channel.js"; +import { imessagePlugin } from "../../../../extensions/imessage/src/channel.js"; +import { ircPlugin } from "../../../../extensions/irc/src/channel.js"; +import { linePlugin } from "../../../../extensions/line/src/channel.js"; +import { setLineRuntime } from "../../../../extensions/line/src/runtime.js"; +import { matrixPlugin } from "../../../../extensions/matrix/src/channel.js"; +import { mattermostPlugin } from "../../../../extensions/mattermost/src/channel.js"; +import { msteamsPlugin } from "../../../../extensions/msteams/src/channel.js"; +import { nextcloudTalkPlugin } from "../../../../extensions/nextcloud-talk/src/channel.js"; +import { nostrPlugin } from "../../../../extensions/nostr/src/channel.js"; +import { signalPlugin } from "../../../../extensions/signal/src/channel.js"; +import { slackPlugin } from "../../../../extensions/slack/src/channel.js"; +import { synologyChatPlugin } from "../../../../extensions/synology-chat/src/channel.js"; +import { telegramPlugin } from "../../../../extensions/telegram/src/channel.js"; +import { setTelegramRuntime } from "../../../../extensions/telegram/src/runtime.js"; +import { tlonPlugin } from "../../../../extensions/tlon/src/channel.js"; +import { whatsappPlugin } from "../../../../extensions/whatsapp/src/channel.js"; +import { zaloPlugin } from "../../../../extensions/zalo/src/channel.js"; +import { zalouserPlugin } from "../../../../extensions/zalouser/src/channel.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { + resolveDefaultLineAccountId, + resolveLineAccount, + listLineAccountIds, +} from "../../../line/accounts.js"; +import type { ChannelPlugin } from "../types.js"; + +type PluginContractEntry = { + id: string; + plugin: Pick; +}; + +type ActionsContractEntry = { + id: string; + plugin: Pick; + unsupportedAction?: string; + cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedActions: string[]; + expectedCapabilities?: string[]; + beforeTest?: () => void; + }>; +}; + +type SetupContractEntry = { + id: string; + plugin: Pick; + cases: Array<{ + name: string; + cfg: OpenClawConfig; + accountId?: string; + input: Record; + expectedAccountId?: string; + expectedValidation?: string | null; + beforeTest?: () => void; + assertPatchedConfig?: (cfg: OpenClawConfig) => void; + assertResolvedAccount?: (account: unknown, cfg: OpenClawConfig) => void; + }>; +}; + +type StatusContractEntry = { + id: string; + plugin: Pick; + cases: Array<{ + name: string; + cfg: OpenClawConfig; + accountId?: string; + runtime?: Record; + probe?: unknown; + beforeTest?: () => void; + assertSnapshot?: (snapshot: Record) => void; + assertSummary?: (summary: Record) => void; + }>; +}; + +const telegramListActionsMock = vi.fn(); +const telegramGetCapabilitiesMock = vi.fn(); +const discordListActionsMock = vi.fn(); +const discordGetCapabilitiesMock = vi.fn(); + +setTelegramRuntime({ + channel: { + telegram: { + messageActions: { + listActions: telegramListActionsMock, + getCapabilities: telegramGetCapabilitiesMock, + }, + }, + }, +} as never); + +setDiscordRuntime({ + channel: { + discord: { + messageActions: { + listActions: discordListActionsMock, + getCapabilities: discordGetCapabilitiesMock, + }, + }, + }, +} as never); + +setLineRuntime({ + channel: { + line: { + listLineAccountIds, + resolveDefaultLineAccountId, + resolveLineAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => + resolveLineAccount({ cfg, accountId }), + }, + }, +} as never); + +export const pluginContractRegistry: PluginContractEntry[] = [ + { id: "bluebubbles", plugin: bluebubblesPlugin }, + { id: "discord", plugin: discordPlugin }, + { id: "feishu", plugin: feishuPlugin }, + { id: "googlechat", plugin: googlechatPlugin }, + { id: "imessage", plugin: imessagePlugin }, + { id: "irc", plugin: ircPlugin }, + { id: "line", plugin: linePlugin }, + { id: "matrix", plugin: matrixPlugin }, + { id: "mattermost", plugin: mattermostPlugin }, + { id: "msteams", plugin: msteamsPlugin }, + { id: "nextcloud-talk", plugin: nextcloudTalkPlugin }, + { id: "nostr", plugin: nostrPlugin }, + { id: "signal", plugin: signalPlugin }, + { id: "slack", plugin: slackPlugin }, + { id: "synology-chat", plugin: synologyChatPlugin }, + { id: "telegram", plugin: telegramPlugin }, + { id: "tlon", plugin: tlonPlugin }, + { id: "whatsapp", plugin: whatsappPlugin }, + { id: "zalo", plugin: zaloPlugin }, + { id: "zalouser", plugin: zalouserPlugin }, +]; + +export const actionContractRegistry: ActionsContractEntry[] = [ + { + id: "slack", + plugin: slackPlugin, + unsupportedAction: "poll", + cases: [ + { + name: "configured account exposes default Slack actions", + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, + } as OpenClawConfig, + expectedActions: [ + "send", + "react", + "reactions", + "read", + "edit", + "delete", + "download-file", + "pin", + "unpin", + "list-pins", + "member-info", + "emoji-list", + ], + expectedCapabilities: ["blocks"], + }, + { + name: "interactive replies add the shared interactive capability", + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + capabilities: { + interactiveReplies: true, + }, + }, + }, + } as OpenClawConfig, + expectedActions: [ + "send", + "react", + "reactions", + "read", + "edit", + "delete", + "download-file", + "pin", + "unpin", + "list-pins", + "member-info", + "emoji-list", + ], + expectedCapabilities: ["blocks", "interactive"], + }, + { + name: "missing tokens disables the actions surface", + cfg: { + channels: { + slack: { + enabled: true, + }, + }, + } as OpenClawConfig, + expectedActions: [], + expectedCapabilities: [], + }, + ], + }, + { + id: "mattermost", + plugin: mattermostPlugin, + unsupportedAction: "poll", + cases: [ + { + name: "configured account exposes send and react", + cfg: { + channels: { + mattermost: { + enabled: true, + botToken: "test-token", + baseUrl: "https://chat.example.com", + }, + }, + } as OpenClawConfig, + expectedActions: ["send", "react"], + expectedCapabilities: ["buttons"], + }, + { + name: "reactions can be disabled while send stays available", + cfg: { + channels: { + mattermost: { + enabled: true, + botToken: "test-token", + baseUrl: "https://chat.example.com", + actions: { reactions: false }, + }, + }, + } as OpenClawConfig, + expectedActions: ["send"], + expectedCapabilities: ["buttons"], + }, + { + name: "missing bot credentials disables the actions surface", + cfg: { + channels: { + mattermost: { + enabled: true, + }, + }, + } as OpenClawConfig, + expectedActions: [], + expectedCapabilities: [], + }, + ], + }, + { + id: "telegram", + plugin: telegramPlugin, + cases: [ + { + name: "forwards runtime-backed Telegram actions and capabilities", + cfg: {} as OpenClawConfig, + expectedActions: ["send", "poll", "react"], + expectedCapabilities: ["interactive", "buttons"], + beforeTest: () => { + telegramListActionsMock.mockReset(); + telegramGetCapabilitiesMock.mockReset(); + telegramListActionsMock.mockReturnValue(["send", "poll", "react"]); + telegramGetCapabilitiesMock.mockReturnValue(["interactive", "buttons"]); + }, + }, + ], + }, + { + id: "discord", + plugin: discordPlugin, + cases: [ + { + name: "forwards runtime-backed Discord actions and capabilities", + cfg: {} as OpenClawConfig, + expectedActions: ["send", "react", "poll"], + expectedCapabilities: ["interactive", "components"], + beforeTest: () => { + discordListActionsMock.mockReset(); + discordGetCapabilitiesMock.mockReset(); + discordListActionsMock.mockReturnValue(["send", "react", "poll"]); + discordGetCapabilitiesMock.mockReturnValue(["interactive", "components"]); + }, + }, + ], + }, +]; + +export const setupContractRegistry: SetupContractEntry[] = [ + { + id: "slack", + plugin: slackPlugin, + cases: [ + { + name: "default account stores tokens and enables the channel", + cfg: {} as OpenClawConfig, + input: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + expectedAccountId: "default", + assertPatchedConfig: (cfg) => { + expect(cfg.channels?.slack?.enabled).toBe(true); + expect(cfg.channels?.slack?.botToken).toBe("xoxb-test"); + expect(cfg.channels?.slack?.appToken).toBe("xapp-test"); + }, + }, + { + name: "non-default env setup is rejected", + cfg: {} as OpenClawConfig, + accountId: "ops", + input: { + useEnv: true, + }, + expectedAccountId: "ops", + expectedValidation: "Slack env tokens can only be used for the default account.", + }, + ], + }, + { + id: "mattermost", + plugin: mattermostPlugin, + cases: [ + { + name: "default account stores token and normalized base URL", + cfg: {} as OpenClawConfig, + input: { + botToken: "test-token", + httpUrl: "https://chat.example.com/", + }, + expectedAccountId: "default", + assertPatchedConfig: (cfg) => { + expect(cfg.channels?.mattermost?.enabled).toBe(true); + expect(cfg.channels?.mattermost?.botToken).toBe("test-token"); + expect(cfg.channels?.mattermost?.baseUrl).toBe("https://chat.example.com"); + }, + }, + { + name: "missing credentials are rejected", + cfg: {} as OpenClawConfig, + input: { + httpUrl: "", + }, + expectedAccountId: "default", + expectedValidation: "Mattermost requires --bot-token and --http-url (or --use-env).", + }, + ], + }, + { + id: "line", + plugin: linePlugin, + cases: [ + { + name: "default account stores token and secret", + cfg: {} as OpenClawConfig, + input: { + channelAccessToken: "line-token", + channelSecret: "line-secret", + }, + expectedAccountId: "default", + assertPatchedConfig: (cfg) => { + expect(cfg.channels?.line?.enabled).toBe(true); + expect(cfg.channels?.line?.channelAccessToken).toBe("line-token"); + expect(cfg.channels?.line?.channelSecret).toBe("line-secret"); + }, + }, + { + name: "non-default env setup is rejected", + cfg: {} as OpenClawConfig, + accountId: "ops", + input: { + useEnv: true, + }, + expectedAccountId: "ops", + expectedValidation: "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.", + }, + ], + }, +]; + +export const statusContractRegistry: StatusContractEntry[] = [ + { + id: "slack", + plugin: slackPlugin, + cases: [ + { + name: "configured account produces a configured status snapshot", + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, + } as OpenClawConfig, + runtime: { + accountId: "default", + connected: true, + running: true, + }, + probe: { ok: true }, + assertSnapshot: (snapshot) => { + expect(snapshot.accountId).toBe("default"); + expect(snapshot.enabled).toBe(true); + expect(snapshot.configured).toBe(true); + }, + }, + ], + }, + { + id: "mattermost", + plugin: mattermostPlugin, + cases: [ + { + name: "configured account preserves connectivity details in the snapshot", + cfg: { + channels: { + mattermost: { + enabled: true, + botToken: "test-token", + baseUrl: "https://chat.example.com", + }, + }, + } as OpenClawConfig, + runtime: { + accountId: "default", + connected: true, + lastConnectedAt: 1234, + }, + probe: { ok: true }, + assertSnapshot: (snapshot) => { + expect(snapshot.accountId).toBe("default"); + expect(snapshot.enabled).toBe(true); + expect(snapshot.configured).toBe(true); + expect(snapshot.connected).toBe(true); + expect(snapshot.baseUrl).toBe("https://chat.example.com"); + }, + }, + ], + }, + { + id: "line", + plugin: linePlugin, + cases: [ + { + name: "configured account produces a webhook status snapshot", + cfg: { + channels: { + line: { + enabled: true, + channelAccessToken: "line-token", + channelSecret: "line-secret", + }, + }, + } as OpenClawConfig, + runtime: { + accountId: "default", + running: true, + }, + probe: { ok: true }, + assertSnapshot: (snapshot) => { + expect(snapshot.accountId).toBe("default"); + expect(snapshot.enabled).toBe(true); + expect(snapshot.configured).toBe(true); + expect(snapshot.mode).toBe("webhook"); + }, + }, + ], + }, +]; From 910d039ea7fce696f84f5345e622698462c4cea8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:45:26 -0700 Subject: [PATCH 054/133] Tests: add global plugin contract suite --- .../plugins/contracts/plugin.contract.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/channels/plugins/contracts/plugin.contract.test.ts diff --git a/src/channels/plugins/contracts/plugin.contract.test.ts b/src/channels/plugins/contracts/plugin.contract.test.ts new file mode 100644 index 00000000000..534d7284456 --- /dev/null +++ b/src/channels/plugins/contracts/plugin.contract.test.ts @@ -0,0 +1,11 @@ +import { describe } from "vitest"; +import { pluginContractRegistry } from "./registry.js"; +import { installChannelPluginContractSuite } from "./suites.js"; + +for (const entry of pluginContractRegistry) { + describe(`${entry.id} plugin contract`, () => { + installChannelPluginContractSuite({ + plugin: entry.plugin, + }); + }); +} From c5d61b96779a795569fb6266bcb292137218ee76 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:45:33 -0700 Subject: [PATCH 055/133] Tests: add global actions contract suite --- .../plugins/contracts/actions.contract.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/channels/plugins/contracts/actions.contract.test.ts diff --git a/src/channels/plugins/contracts/actions.contract.test.ts b/src/channels/plugins/contracts/actions.contract.test.ts new file mode 100644 index 00000000000..11daa8cd205 --- /dev/null +++ b/src/channels/plugins/contracts/actions.contract.test.ts @@ -0,0 +1,13 @@ +import { describe } from "vitest"; +import { actionContractRegistry } from "./registry.js"; +import { installChannelActionsContractSuite } from "./suites.js"; + +for (const entry of actionContractRegistry) { + describe(`${entry.id} actions contract`, () => { + installChannelActionsContractSuite({ + plugin: entry.plugin, + cases: entry.cases as never, + unsupportedAction: entry.unsupportedAction as never, + }); + }); +} From acf7e83ac4a801913eba46723b1c2a549c0e11d0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:45:44 -0700 Subject: [PATCH 056/133] Tests: add global setup contract suite --- .../plugins/contracts/setup.contract.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/channels/plugins/contracts/setup.contract.test.ts diff --git a/src/channels/plugins/contracts/setup.contract.test.ts b/src/channels/plugins/contracts/setup.contract.test.ts new file mode 100644 index 00000000000..acc221d61d6 --- /dev/null +++ b/src/channels/plugins/contracts/setup.contract.test.ts @@ -0,0 +1,12 @@ +import { describe } from "vitest"; +import { setupContractRegistry } from "./registry.js"; +import { installChannelSetupContractSuite } from "./suites.js"; + +for (const entry of setupContractRegistry) { + describe(`${entry.id} setup contract`, () => { + installChannelSetupContractSuite({ + plugin: entry.plugin, + cases: entry.cases as never, + }); + }); +} From 9df7e8bec45ed8b6d18cf9c221d4ac20f3068726 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:45:52 -0700 Subject: [PATCH 057/133] Tests: add global status contract suite --- .../plugins/contracts/status.contract.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/channels/plugins/contracts/status.contract.test.ts diff --git a/src/channels/plugins/contracts/status.contract.test.ts b/src/channels/plugins/contracts/status.contract.test.ts new file mode 100644 index 00000000000..9cc364f17d2 --- /dev/null +++ b/src/channels/plugins/contracts/status.contract.test.ts @@ -0,0 +1,12 @@ +import { describe } from "vitest"; +import { statusContractRegistry } from "./registry.js"; +import { installChannelStatusContractSuite } from "./suites.js"; + +for (const entry of statusContractRegistry) { + describe(`${entry.id} status contract`, () => { + installChannelStatusContractSuite({ + plugin: entry.plugin, + cases: entry.cases as never, + }); + }); +} From f5ef936615aaa2025270752c9603bbb0ed24925f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:46:05 -0700 Subject: [PATCH 058/133] Tests: replace local channel contracts --- .../discord/src/channel.contract.test.ts | 49 ----------- .../mattermost/src/channel.contract.test.ts | 59 ------------- extensions/slack/src/channel.contract.test.ts | 85 ------------------- .../telegram/src/channel.contract.test.ts | 49 ----------- src/test-utils/channel-actions-contract.ts | 53 ------------ src/test-utils/channel-plugin-contract.ts | 24 ------ 6 files changed, 319 deletions(-) delete mode 100644 extensions/discord/src/channel.contract.test.ts delete mode 100644 extensions/mattermost/src/channel.contract.test.ts delete mode 100644 extensions/slack/src/channel.contract.test.ts delete mode 100644 extensions/telegram/src/channel.contract.test.ts delete mode 100644 src/test-utils/channel-actions-contract.ts delete mode 100644 src/test-utils/channel-plugin-contract.ts diff --git a/extensions/discord/src/channel.contract.test.ts b/extensions/discord/src/channel.contract.test.ts deleted file mode 100644 index 3a651e6e26b..00000000000 --- a/extensions/discord/src/channel.contract.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/discord"; -import { afterEach, describe, vi } from "vitest"; -import { installChannelActionsContractSuite } from "../../../src/test-utils/channel-actions-contract.js"; -import { installChannelPluginContractSuite } from "../../../src/test-utils/channel-plugin-contract.js"; - -const discordListActionsMock = vi.fn(); -const discordGetCapabilitiesMock = vi.fn(); - -vi.mock("./runtime.js", () => ({ - getDiscordRuntime: () => ({ - channel: { - discord: { - messageActions: { - listActions: discordListActionsMock, - getCapabilities: discordGetCapabilitiesMock, - }, - }, - }, - }), -})); - -const { discordPlugin } = await import("./channel.js"); - -describe("discordPlugin contract", () => { - afterEach(() => { - discordListActionsMock.mockReset(); - discordGetCapabilitiesMock.mockReset(); - }); - - installChannelPluginContractSuite({ - plugin: discordPlugin, - }); - - installChannelActionsContractSuite({ - plugin: discordPlugin, - cases: [ - { - name: "forwards runtime-backed Discord actions and capabilities", - cfg: {} as OpenClawConfig, - expectedActions: ["send", "react", "poll"], - expectedCapabilities: ["interactive", "components"], - beforeTest: () => { - discordListActionsMock.mockReturnValue(["send", "react", "poll"]); - discordGetCapabilitiesMock.mockReturnValue(["interactive", "components"]); - }, - }, - ], - }); -}); diff --git a/extensions/mattermost/src/channel.contract.test.ts b/extensions/mattermost/src/channel.contract.test.ts deleted file mode 100644 index 96f5fe9ed4a..00000000000 --- a/extensions/mattermost/src/channel.contract.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; -import { describe } from "vitest"; -import { installChannelActionsContractSuite } from "../../../src/test-utils/channel-actions-contract.js"; -import { installChannelPluginContractSuite } from "../../../src/test-utils/channel-plugin-contract.js"; -import { mattermostPlugin } from "./channel.js"; - -describe("mattermostPlugin contract", () => { - installChannelPluginContractSuite({ - plugin: mattermostPlugin, - }); - - installChannelActionsContractSuite({ - plugin: mattermostPlugin, - unsupportedAction: "poll", - cases: [ - { - name: "configured account exposes send and react", - cfg: { - channels: { - mattermost: { - enabled: true, - botToken: "test-token", - baseUrl: "https://chat.example.com", - }, - }, - } as OpenClawConfig, - expectedActions: ["send", "react"], - expectedCapabilities: ["buttons"], - }, - { - name: "reactions can be disabled while send stays available", - cfg: { - channels: { - mattermost: { - enabled: true, - botToken: "test-token", - baseUrl: "https://chat.example.com", - actions: { reactions: false }, - }, - }, - } as OpenClawConfig, - expectedActions: ["send"], - expectedCapabilities: ["buttons"], - }, - { - name: "missing bot credentials disables the actions surface", - cfg: { - channels: { - mattermost: { - enabled: true, - }, - }, - } as OpenClawConfig, - expectedActions: [], - expectedCapabilities: [], - }, - ], - }); -}); diff --git a/extensions/slack/src/channel.contract.test.ts b/extensions/slack/src/channel.contract.test.ts deleted file mode 100644 index 3fd0e23dab5..00000000000 --- a/extensions/slack/src/channel.contract.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/slack"; -import { describe } from "vitest"; -import { installChannelActionsContractSuite } from "../../../src/test-utils/channel-actions-contract.js"; -import { installChannelPluginContractSuite } from "../../../src/test-utils/channel-plugin-contract.js"; -import { slackPlugin } from "./channel.js"; - -describe("slackPlugin contract", () => { - installChannelPluginContractSuite({ - plugin: slackPlugin, - }); - - installChannelActionsContractSuite({ - plugin: slackPlugin, - unsupportedAction: "poll", - cases: [ - { - name: "configured account exposes default Slack actions", - cfg: { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", - }, - }, - } as OpenClawConfig, - expectedActions: [ - "send", - "react", - "reactions", - "read", - "edit", - "delete", - "download-file", - "pin", - "unpin", - "list-pins", - "member-info", - "emoji-list", - ], - expectedCapabilities: ["blocks"], - }, - { - name: "interactive replies add the shared interactive capability", - cfg: { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", - capabilities: { - interactiveReplies: true, - }, - }, - }, - } as OpenClawConfig, - expectedActions: [ - "send", - "react", - "reactions", - "read", - "edit", - "delete", - "download-file", - "pin", - "unpin", - "list-pins", - "member-info", - "emoji-list", - ], - expectedCapabilities: ["blocks", "interactive"], - }, - { - name: "missing tokens disables the actions surface", - cfg: { - channels: { - slack: { - enabled: true, - }, - }, - } as OpenClawConfig, - expectedActions: [], - expectedCapabilities: [], - }, - ], - }); -}); diff --git a/extensions/telegram/src/channel.contract.test.ts b/extensions/telegram/src/channel.contract.test.ts deleted file mode 100644 index 164f862949d..00000000000 --- a/extensions/telegram/src/channel.contract.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/telegram"; -import { afterEach, describe, vi } from "vitest"; -import { installChannelActionsContractSuite } from "../../../src/test-utils/channel-actions-contract.js"; -import { installChannelPluginContractSuite } from "../../../src/test-utils/channel-plugin-contract.js"; - -const telegramListActionsMock = vi.fn(); -const telegramGetCapabilitiesMock = vi.fn(); - -vi.mock("./runtime.js", () => ({ - getTelegramRuntime: () => ({ - channel: { - telegram: { - messageActions: { - listActions: telegramListActionsMock, - getCapabilities: telegramGetCapabilitiesMock, - }, - }, - }, - }), -})); - -const { telegramPlugin } = await import("./channel.js"); - -describe("telegramPlugin contract", () => { - afterEach(() => { - telegramListActionsMock.mockReset(); - telegramGetCapabilitiesMock.mockReset(); - }); - - installChannelPluginContractSuite({ - plugin: telegramPlugin, - }); - - installChannelActionsContractSuite({ - plugin: telegramPlugin, - cases: [ - { - name: "forwards runtime-backed Telegram actions and capabilities", - cfg: {} as OpenClawConfig, - expectedActions: ["send", "poll", "react"], - expectedCapabilities: ["interactive", "buttons"], - beforeTest: () => { - telegramListActionsMock.mockReturnValue(["send", "poll", "react"]); - telegramGetCapabilitiesMock.mockReturnValue(["interactive", "buttons"]); - }, - }, - ], - }); -}); diff --git a/src/test-utils/channel-actions-contract.ts b/src/test-utils/channel-actions-contract.ts deleted file mode 100644 index 12d7d7046f5..00000000000 --- a/src/test-utils/channel-actions-contract.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect, it } from "vitest"; -import type { ChannelMessageCapability } from "../channels/plugins/message-capabilities.js"; -import type { ChannelMessageActionName, ChannelPlugin } from "../channels/plugins/types.js"; -import type { OpenClawConfig } from "../config/config.js"; - -type ChannelActionsContractCase = { - name: string; - cfg: OpenClawConfig; - expectedActions: readonly ChannelMessageActionName[]; - expectedCapabilities?: readonly ChannelMessageCapability[]; - beforeTest?: () => void; -}; - -export function installChannelActionsContractSuite(params: { - plugin: Pick; - cases: readonly ChannelActionsContractCase[]; - unsupportedAction?: ChannelMessageActionName; -}) { - it("exposes the base message actions contract", () => { - expect(params.plugin.actions).toBeDefined(); - expect(typeof params.plugin.actions?.listActions).toBe("function"); - }); - - for (const testCase of params.cases) { - it(`actions contract: ${testCase.name}`, () => { - testCase.beforeTest?.(); - - const actions = params.plugin.actions?.listActions?.({ cfg: testCase.cfg }) ?? []; - const capabilities = params.plugin.actions?.getCapabilities?.({ cfg: testCase.cfg }) ?? []; - - expect(actions).toEqual([...new Set(actions)]); - expect(capabilities).toEqual([...new Set(capabilities)]); - expect(actions.toSorted()).toEqual([...testCase.expectedActions].toSorted()); - expect(capabilities.toSorted()).toEqual( - [...(testCase.expectedCapabilities ?? [])].toSorted(), - ); - - if (params.plugin.actions?.supportsAction) { - for (const action of testCase.expectedActions) { - expect(params.plugin.actions.supportsAction({ action })).toBe(true); - } - if ( - params.unsupportedAction && - !testCase.expectedActions.includes(params.unsupportedAction) - ) { - expect(params.plugin.actions.supportsAction({ action: params.unsupportedAction })).toBe( - false, - ); - } - } - }); - } -} diff --git a/src/test-utils/channel-plugin-contract.ts b/src/test-utils/channel-plugin-contract.ts deleted file mode 100644 index b2912befd0b..00000000000 --- a/src/test-utils/channel-plugin-contract.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, it } from "vitest"; -import type { ChannelPlugin } from "../channels/plugins/types.js"; - -export function installChannelPluginContractSuite(params: { - plugin: Pick; -}) { - it("satisfies the base channel plugin contract", () => { - const { plugin } = params; - - expect(typeof plugin.id).toBe("string"); - expect(plugin.id.trim()).not.toBe(""); - - expect(plugin.meta.id).toBe(plugin.id); - expect(plugin.meta.label.trim()).not.toBe(""); - expect(plugin.meta.selectionLabel.trim()).not.toBe(""); - expect(plugin.meta.docsPath).toMatch(/^\/channels\//); - expect(plugin.meta.blurb.trim()).not.toBe(""); - - expect(plugin.capabilities.chatTypes.length).toBeGreaterThan(0); - - expect(typeof plugin.config.listAccountIds).toBe("function"); - expect(typeof plugin.config.resolveAccount).toBe("function"); - }); -} From ae60094fb55659bd93723f7f97a8173a43d24c04 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 23:47:07 -0700 Subject: [PATCH 059/133] refactor(plugins): move onboarding auth metadata to manifests --- docs/plugins/manifest.md | 40 +++ docs/tools/plugin.md | 20 +- extensions/anthropic/openclaw.plugin.json | 25 ++ extensions/byteplus/index.ts | 30 +- extensions/byteplus/openclaw.plugin.json | 15 + extensions/cloudflare-ai-gateway/index.ts | 196 ++++++++++- .../openclaw.plugin.json | 16 + extensions/copilot-proxy/openclaw.plugin.json | 12 + .../github-copilot/openclaw.plugin.json | 12 + extensions/google/openclaw.plugin.json | 25 ++ extensions/huggingface/index.ts | 29 +- extensions/huggingface/openclaw.plugin.json | 16 + extensions/kilocode/index.ts | 28 +- extensions/kilocode/openclaw.plugin.json | 16 + extensions/kimi-coding/index.ts | 30 +- extensions/kimi-coding/openclaw.plugin.json | 15 + extensions/minimax/openclaw.plugin.json | 50 +++ extensions/mistral/index.ts | 25 +- extensions/mistral/openclaw.plugin.json | 15 + extensions/modelstudio/index.ts | 63 +++- extensions/modelstudio/openclaw.plugin.json | 30 ++ extensions/moonshot/index.ts | 49 ++- extensions/moonshot/openclaw.plugin.json | 28 ++ extensions/ollama/openclaw.plugin.json | 12 + extensions/openai/openclaw.plugin.json | 25 ++ extensions/opencode-go/index.ts | 32 +- extensions/opencode-go/openclaw.plugin.json | 15 + extensions/opencode/index.ts | 33 +- extensions/opencode/openclaw.plugin.json | 15 + extensions/openrouter/index.ts | 28 +- extensions/openrouter/openclaw.plugin.json | 15 + extensions/qianfan/index.ts | 25 +- extensions/qianfan/openclaw.plugin.json | 15 + .../qwen-portal-auth/openclaw.plugin.json | 12 + extensions/sglang/openclaw.plugin.json | 12 + extensions/synthetic/index.ts | 28 +- extensions/synthetic/openclaw.plugin.json | 15 + extensions/together/index.ts | 28 +- extensions/together/openclaw.plugin.json | 15 + extensions/venice/index.ts | 31 +- extensions/venice/openclaw.plugin.json | 15 + extensions/vercel-ai-gateway/index.ts | 28 +- .../vercel-ai-gateway/openclaw.plugin.json | 15 + extensions/vllm/openclaw.plugin.json | 12 + extensions/volcengine/index.ts | 30 +- extensions/volcengine/openclaw.plugin.json | 15 + extensions/xai/index.ts | 28 +- extensions/xai/openclaw.plugin.json | 15 + extensions/xiaomi/index.ts | 25 +- extensions/xiaomi/openclaw.plugin.json | 15 + extensions/zai/index.ts | 194 ++++++++++- extensions/zai/openclaw.plugin.json | 71 ++++ src/cli/program/register.onboard.test.ts | 21 +- src/cli/program/register.onboard.ts | 52 ++- src/commands/auth-choice-options.static.ts | 308 ++---------------- src/commands/auth-choice-options.test.ts | 209 +++++++++++- src/commands/auth-choice-options.ts | 123 ++++--- .../auth-choice.apply.anthropic.test.ts | 105 ------ src/commands/auth-choice.apply.anthropic.ts | 64 ---- .../auth-choice.apply.api-providers.ts | 27 +- src/commands/auth-choice.apply.openai.test.ts | 116 ------- src/commands/auth-choice.apply.openai.ts | 80 ----- src/commands/auth-choice.apply.ts | 13 +- .../auth-choice.preferred-provider.test.ts | 21 ++ .../auth-choice.preferred-provider.ts | 45 +-- src/commands/auth-choice.test.ts | 28 +- src/commands/onboard-core-auth-flags.ts | 21 ++ .../local/auth-choice-inference.ts | 60 +--- src/commands/onboard-provider-auth-flags.ts | 225 ------------- src/plugin-sdk/core.ts | 1 + src/plugins/manifest-registry.test.ts | 16 + src/plugins/manifest-registry.ts | 2 + src/plugins/manifest.ts | 74 +++++ src/plugins/provider-api-key-auth.ts | 7 +- src/plugins/provider-auth-choices.test.ts | 92 ++++++ src/plugins/provider-auth-choices.ts | 102 ++++++ src/secrets/provider-env-vars.ts | 18 +- 77 files changed, 2334 insertions(+), 1100 deletions(-) delete mode 100644 src/commands/auth-choice.apply.anthropic.test.ts delete mode 100644 src/commands/auth-choice.apply.anthropic.ts delete mode 100644 src/commands/auth-choice.apply.openai.test.ts delete mode 100644 src/commands/auth-choice.apply.openai.ts create mode 100644 src/commands/onboard-core-auth-flags.ts delete mode 100644 src/commands/onboard-provider-auth-flags.ts create mode 100644 src/plugins/provider-auth-choices.test.ts create mode 100644 src/plugins/provider-auth-choices.ts diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 01d5e0d3578..5ef77b9ef68 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -59,12 +59,49 @@ Optional keys: - `providerAuthEnvVars` (object): auth env vars keyed by provider id. Use this when OpenClaw should resolve provider credentials from env without loading plugin runtime first. +- `providerAuthChoices` (array): cheap onboarding/auth-choice metadata keyed by + provider + auth method. Use this when OpenClaw should show a provider in + auth-choice pickers, preferred-provider resolution, and CLI help without + loading plugin runtime first. - `skills` (array): skill directories to load (relative to the plugin root). - `name` (string): display name for the plugin. - `description` (string): short plugin summary. - `uiHints` (object): config field labels/placeholders/sensitive flags for UI rendering. - `version` (string): plugin version (informational). +### `providerAuthChoices` shape + +Each entry can declare: + +- `provider`: provider id +- `method`: auth method id +- `choiceId`: stable onboarding/auth-choice id +- `choiceLabel` / `choiceHint`: picker label + short hint +- `groupId` / `groupLabel` / `groupHint`: grouped onboarding bucket metadata +- `optionKey` / `cliFlag` / `cliOption` / `cliDescription`: optional one-flag + CLI wiring for simple auth flows such as API keys + +Example: + +```json +{ + "providerAuthChoices": [ + { + "provider": "openrouter", + "method": "api-key", + "choiceId": "openrouter-api-key", + "choiceLabel": "OpenRouter API key", + "groupId": "openrouter", + "groupLabel": "OpenRouter", + "optionKey": "openrouterApiKey", + "cliFlag": "--openrouter-api-key", + "cliOption": "--openrouter-api-key ", + "cliDescription": "OpenRouter API key" + } + ] +} +``` + ## JSON Schema requirements - **Every plugin must ship a JSON Schema**, even if it accepts no config. @@ -90,6 +127,9 @@ Optional keys: - `providerAuthEnvVars` is the cheap metadata path for auth probes, env-marker validation, and similar provider-auth surfaces that should not boot plugin runtime just to inspect env names. +- `providerAuthChoices` is the cheap metadata path for auth-choice pickers, + `--auth-choice` resolution, preferred-provider mapping, and simple onboarding + CLI flag registration before provider runtime loads. - Exclusive plugin kinds are selected through `plugins.slots.*`. - `kind: "memory"` is selected by `plugins.slots.memory`. - `kind: "context-engine"` is selected by `plugins.slots.contextEngine` diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index ec4084eeca6..a53a05bf6c0 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -218,7 +218,8 @@ Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). Provider plugins now have two layers: - manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before - runtime load + runtime load, plus `providerAuthChoices` for cheap onboarding/auth-choice + labels and CLI flag metadata before runtime load - config-time hooks: `catalog` / legacy `discovery` - runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `formatApiKey`, `refreshOAuth`, `buildAuthDoctorHint`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `isBinaryThinking`, `supportsXHighThinking`, `resolveDefaultThinkingLevel`, `isModernModelRef`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` @@ -228,8 +229,11 @@ needing a whole custom inference transport. Use manifest `providerAuthEnvVars` when the provider has env-based credentials that generic auth/status/model-picker paths should see without loading plugin -runtime. Keep provider runtime `envVars` for operator-facing hints such as -onboarding labels or OAuth client-id/client-secret setup vars. +runtime. Use manifest `providerAuthChoices` when onboarding/auth-choice CLI +surfaces should know the provider's choice id, group labels, and simple +one-flag auth wiring without loading provider runtime. Keep provider runtime +`envVars` for operator-facing hints such as onboarding labels or OAuth +client-id/client-secret setup vars. ### Hook order @@ -1266,6 +1270,16 @@ errors instead. ### Provider wizard metadata +Provider auth/onboarding metadata can live in two layers: + +- manifest `providerAuthChoices`: cheap labels, grouping, `--auth-choice` + ids, and simple CLI flag metadata available before runtime load +- runtime `wizard.setup` / `auth[].wizard`: richer behavior that depends on + loaded provider code + +Use manifest metadata for static labels/flags. Use runtime wizard metadata when +setup depends on dynamic auth methods, method fallback, or runtime validation. + `wizard.setup` controls how the provider appears in grouped onboarding: - `choiceId`: auth-choice value diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index aec972801f8..65a57bd0381 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -4,6 +4,31 @@ "providerAuthEnvVars": { "anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "anthropic", + "method": "setup-token", + "choiceId": "token", + "choiceLabel": "Anthropic token (paste setup-token)", + "choiceHint": "Run `claude setup-token` elsewhere, then paste the token here", + "groupId": "anthropic", + "groupLabel": "Anthropic", + "groupHint": "setup-token + API key" + }, + { + "provider": "anthropic", + "method": "api-key", + "choiceId": "apiKey", + "choiceLabel": "Anthropic API key", + "groupId": "anthropic", + "groupLabel": "Anthropic", + "groupHint": "setup-token + API key", + "optionKey": "anthropicApiKey", + "cliFlag": "--anthropic-api-key", + "cliOption": "--anthropic-api-key ", + "cliDescription": "Anthropic API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/byteplus/index.ts b/extensions/byteplus/index.ts index 35050f2c789..d32263014c6 100644 --- a/extensions/byteplus/index.ts +++ b/extensions/byteplus/index.ts @@ -3,8 +3,11 @@ 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"; const PROVIDER_ID = "byteplus"; +const BYTEPLUS_DEFAULT_MODEL_REF = "byteplus-plan/ark-code-latest"; const byteplusPlugin = { id: PROVIDER_ID, @@ -17,7 +20,32 @@ const byteplusPlugin = { label: "BytePlus", docsPath: "/concepts/model-providers#byteplus-international", envVars: ["BYTEPLUS_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "BytePlus API key", + hint: "API key", + optionKey: "byteplusApiKey", + flagName: "--byteplus-api-key", + envVar: "BYTEPLUS_API_KEY", + promptMessage: "Enter BytePlus API key", + defaultModel: BYTEPLUS_DEFAULT_MODEL_REF, + expectedProviders: ["byteplus"], + applyConfig: (cfg) => + ensureModelAllowlistEntry({ + cfg, + modelRef: BYTEPLUS_DEFAULT_MODEL_REF, + }), + wizard: { + choiceId: "byteplus-api-key", + choiceLabel: "BytePlus API key", + groupId: "byteplus", + groupLabel: "BytePlus", + groupHint: "API key", + }, + }), + ], catalog: { order: "paired", run: async (ctx) => { diff --git a/extensions/byteplus/openclaw.plugin.json b/extensions/byteplus/openclaw.plugin.json index abef4351a48..f24abe730a3 100644 --- a/extensions/byteplus/openclaw.plugin.json +++ b/extensions/byteplus/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "byteplus": ["BYTEPLUS_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "byteplus", + "method": "api-key", + "choiceId": "byteplus-api-key", + "choiceLabel": "BytePlus API key", + "groupId": "byteplus", + "groupLabel": "BytePlus", + "groupHint": "API key", + "optionKey": "byteplusApiKey", + "cliFlag": "--byteplus-api-key", + "cliOption": "--byteplus-api-key ", + "cliDescription": "BytePlus API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts index 173c9eaf48b..4544a932faf 100644 --- a/extensions/cloudflare-ai-gateway/index.ts +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -1,14 +1,29 @@ 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 { 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/onboard-auth.credentials.js"; +import { + applyCloudflareAiGatewayConfig, + applyAuthProfileConfig, + CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, +} 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"; const PROVIDER_ID = "cloudflare-ai-gateway"; const PROVIDER_ENV_VAR = "CLOUDFLARE_AI_GATEWAY_API_KEY"; +const PROFILE_ID = "cloudflare-ai-gateway:default"; function resolveApiKeyFromCredential( cred: ReturnType["profiles"][string] | undefined, @@ -26,6 +41,71 @@ function resolveApiKeyFromCredential( return cred.key?.trim() || undefined; } +function resolveMetadataFromCredential( + cred: ReturnType["profiles"][string] | undefined, +): { accountId?: string; gatewayId?: string } { + if (!cred || cred.type !== "api_key") { + return {}; + } + return { + accountId: cred?.metadata?.accountId?.trim() || undefined, + gatewayId: cred?.metadata?.gatewayId?.trim() || undefined, + }; +} + +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; + prompter: { + text: (params: { + message: string; + validate?: (value: unknown) => string | undefined; + }) => Promise; + }; +}) { + let accountId = ctx.accountId?.trim() ?? ""; + let gatewayId = ctx.gatewayId?.trim() ?? ""; + if (!accountId) { + const value = await ctx.prompter.text({ + message: "Enter Cloudflare Account ID", + validate: (val) => (String(val ?? "").trim() ? undefined : "Account ID is required"), + }); + accountId = String(value ?? "").trim(); + } + if (!gatewayId) { + const value = await ctx.prompter.text({ + message: "Enter Cloudflare AI Gateway ID", + validate: (val) => (String(val ?? "").trim() ? undefined : "Gateway ID is required"), + }); + gatewayId = String(value ?? "").trim(); + } + return { accountId, gatewayId }; +} + const cloudflareAiGatewayPlugin = { id: PROVIDER_ID, name: "Cloudflare AI Gateway Provider", @@ -37,7 +117,121 @@ const cloudflareAiGatewayPlugin = { label: "Cloudflare AI Gateway", docsPath: "/providers/cloudflare-ai-gateway", envVars: ["CLOUDFLARE_AI_GATEWAY_API_KEY"], - auth: [], + auth: [ + { + id: "api-key", + label: "Cloudflare AI Gateway", + hint: "Account ID + Gateway ID + API key", + kind: "api_key", + wizard: { + choiceId: "cloudflare-ai-gateway-api-key", + choiceLabel: "Cloudflare AI Gateway", + choiceHint: "Account ID + Gateway ID + API key", + groupId: "cloudflare-ai-gateway", + groupLabel: "Cloudflare AI Gateway", + groupHint: "Account ID + Gateway ID + API key", + }, + run: async (ctx) => { + const metadata = await resolveCloudflareGatewayMetadataInteractive({ + accountId: normalizeOptionalSecretInput(ctx.opts?.cloudflareAiGatewayAccountId), + gatewayId: normalizeOptionalSecretInput(ctx.opts?.cloudflareAiGatewayGatewayId), + prompter: ctx.prompter, + }); + let capturedSecretInput: SecretInput | undefined; + let capturedCredential = false; + let capturedMode: "plaintext" | "ref" | undefined; + await ensureApiKeyFromOptionEnvOrPrompt({ + token: normalizeOptionalSecretInput(ctx.opts?.cloudflareAiGatewayApiKey), + tokenProvider: "cloudflare-ai-gateway", + secretInputMode: ctx.secretInputMode, + config: ctx.config, + expectedProviders: [PROVIDER_ID], + provider: PROVIDER_ID, + envLabel: PROVIDER_ENV_VAR, + promptMessage: "Enter Cloudflare AI Gateway API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: ctx.prompter, + setCredential: async (apiKey, mode) => { + capturedSecretInput = apiKey; + capturedCredential = true; + capturedMode = mode; + }, + }); + if (!capturedCredential) { + throw new Error("Missing Cloudflare AI Gateway API key."); + } + const credentialInput = capturedSecretInput ?? ""; + return { + profiles: [ + { + profileId: PROFILE_ID, + credential: buildApiKeyCredential( + PROVIDER_ID, + credentialInput, + { + accountId: metadata.accountId, + gatewayId: metadata.gatewayId, + }, + capturedMode ? { secretInputMode: capturedMode } : undefined, + ), + }, + ], + configPatch: buildCloudflareConfigPatch(metadata), + defaultModel: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, + }; + }, + runNonInteractive: async (ctx) => { + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const storedMetadata = resolveMetadataFromCredential(authStore.profiles[PROFILE_ID]); + const accountId = + normalizeOptionalSecretInput(ctx.opts.cloudflareAiGatewayAccountId) ?? + storedMetadata.accountId; + const gatewayId = + normalizeOptionalSecretInput(ctx.opts.cloudflareAiGatewayGatewayId) ?? + storedMetadata.gatewayId; + if (!accountId || !gatewayId) { + ctx.runtime.error( + "Cloudflare AI Gateway setup requires --cloudflare-ai-gateway-account-id and --cloudflare-ai-gateway-gateway-id.", + ); + ctx.runtime.exit(1); + return null; + } + const resolved = await ctx.resolveApiKey({ + provider: PROVIDER_ID, + flagValue: normalizeOptionalSecretInput(ctx.opts.cloudflareAiGatewayApiKey), + flagName: "--cloudflare-ai-gateway-api-key", + envVar: PROVIDER_ENV_VAR, + }); + if (!resolved) { + return null; + } + if (resolved.source !== "profile") { + const credential = ctx.toApiKeyCredential({ + provider: PROVIDER_ID, + resolved, + metadata: { accountId, gatewayId }, + }); + if (!credential) { + return null; + } + upsertAuthProfile({ + profileId: PROFILE_ID, + credential, + agentDir: ctx.agentDir, + }); + } + const next = applyAuthProfileConfig(ctx.config, { + profileId: PROFILE_ID, + provider: PROVIDER_ID, + mode: "api_key", + }); + return applyCloudflareAiGatewayConfig(next, { accountId, gatewayId }); + }, + }, + ], catalog: { order: "late", run: async (ctx) => { diff --git a/extensions/cloudflare-ai-gateway/openclaw.plugin.json b/extensions/cloudflare-ai-gateway/openclaw.plugin.json index ca7810e1fd2..daa9d363b54 100644 --- a/extensions/cloudflare-ai-gateway/openclaw.plugin.json +++ b/extensions/cloudflare-ai-gateway/openclaw.plugin.json @@ -4,6 +4,22 @@ "providerAuthEnvVars": { "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "cloudflare-ai-gateway", + "method": "api-key", + "choiceId": "cloudflare-ai-gateway-api-key", + "choiceLabel": "Cloudflare AI Gateway", + "choiceHint": "Account ID + Gateway ID + API key", + "groupId": "cloudflare-ai-gateway", + "groupLabel": "Cloudflare AI Gateway", + "groupHint": "Account ID + Gateway ID + API key", + "optionKey": "cloudflareAiGatewayApiKey", + "cliFlag": "--cloudflare-ai-gateway-api-key", + "cliOption": "--cloudflare-ai-gateway-api-key ", + "cliDescription": "Cloudflare AI Gateway API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/copilot-proxy/openclaw.plugin.json b/extensions/copilot-proxy/openclaw.plugin.json index 90e2011f014..88b5ee0e7b3 100644 --- a/extensions/copilot-proxy/openclaw.plugin.json +++ b/extensions/copilot-proxy/openclaw.plugin.json @@ -1,6 +1,18 @@ { "id": "copilot-proxy", "providers": ["copilot-proxy"], + "providerAuthChoices": [ + { + "provider": "copilot-proxy", + "method": "local", + "choiceId": "copilot-proxy", + "choiceLabel": "Copilot Proxy", + "choiceHint": "Configure base URL + model ids", + "groupId": "copilot", + "groupLabel": "Copilot", + "groupHint": "GitHub + local proxy" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index a6cb5b7f4b5..ec05c52b48c 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -4,6 +4,18 @@ "providerAuthEnvVars": { "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] }, + "providerAuthChoices": [ + { + "provider": "github-copilot", + "method": "device", + "choiceId": "github-copilot", + "choiceLabel": "GitHub Copilot", + "choiceHint": "Device login with your GitHub account", + "groupId": "copilot", + "groupLabel": "Copilot", + "groupHint": "GitHub + local proxy" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 0d64bb18c14..b779c292375 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -4,6 +4,31 @@ "providerAuthEnvVars": { "google": ["GEMINI_API_KEY", "GOOGLE_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "google", + "method": "api-key", + "choiceId": "gemini-api-key", + "choiceLabel": "Google Gemini API key", + "groupId": "google", + "groupLabel": "Google", + "groupHint": "Gemini API key + OAuth", + "optionKey": "geminiApiKey", + "cliFlag": "--gemini-api-key", + "cliOption": "--gemini-api-key ", + "cliDescription": "Gemini API key" + }, + { + "provider": "google-gemini-cli", + "method": "oauth", + "choiceId": "google-gemini-cli", + "choiceLabel": "Gemini CLI OAuth", + "choiceHint": "Google OAuth with project-aware token payload", + "groupId": "google", + "groupLabel": "Google", + "groupHint": "Gemini API key + OAuth" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/huggingface/index.ts b/extensions/huggingface/index.ts index c6407954811..c1cea578349 100644 --- a/extensions/huggingface/index.ts +++ b/extensions/huggingface/index.ts @@ -1,5 +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"; const PROVIDER_ID = "huggingface"; @@ -14,7 +19,29 @@ const huggingfacePlugin = { label: "Hugging Face", docsPath: "/providers/huggingface", envVars: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Hugging Face API key", + hint: "Inference API (HF token)", + optionKey: "huggingfaceApiKey", + flagName: "--huggingface-api-key", + envVar: "HUGGINGFACE_HUB_TOKEN", + promptMessage: "Enter Hugging Face API key", + defaultModel: HUGGINGFACE_DEFAULT_MODEL_REF, + expectedProviders: ["huggingface"], + applyConfig: (cfg) => applyHuggingfaceConfig(cfg), + wizard: { + choiceId: "huggingface-api-key", + choiceLabel: "Hugging Face API key", + choiceHint: "Inference API (HF token)", + groupId: "huggingface", + groupLabel: "Hugging Face", + groupHint: "Inference API (HF token)", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/huggingface/openclaw.plugin.json b/extensions/huggingface/openclaw.plugin.json index 67a34124d0a..84d8fd0e6bf 100644 --- a/extensions/huggingface/openclaw.plugin.json +++ b/extensions/huggingface/openclaw.plugin.json @@ -4,6 +4,22 @@ "providerAuthEnvVars": { "huggingface": ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"] }, + "providerAuthChoices": [ + { + "provider": "huggingface", + "method": "api-key", + "choiceId": "huggingface-api-key", + "choiceLabel": "Hugging Face API key", + "choiceHint": "Inference API (HF token)", + "groupId": "huggingface", + "groupLabel": "Hugging Face", + "groupHint": "Inference API (HF token)", + "optionKey": "huggingfaceApiKey", + "cliFlag": "--huggingface-api-key", + "cliOption": "--huggingface-api-key ", + "cliDescription": "Hugging Face API key (HF token)" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/kilocode/index.ts b/extensions/kilocode/index.ts index 10fc30f67d4..5fff1fd061b 100644 --- a/extensions/kilocode/index.ts +++ b/extensions/kilocode/index.ts @@ -4,6 +4,11 @@ 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"; const PROVIDER_ID = "kilocode"; @@ -18,7 +23,28 @@ const kilocodePlugin = { label: "Kilo Gateway", docsPath: "/providers/kilocode", envVars: ["KILOCODE_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Kilo Gateway API key", + hint: "API key (OpenRouter-compatible)", + optionKey: "kilocodeApiKey", + flagName: "--kilocode-api-key", + envVar: "KILOCODE_API_KEY", + promptMessage: "Enter Kilo Gateway API key", + defaultModel: KILOCODE_DEFAULT_MODEL_REF, + expectedProviders: ["kilocode"], + applyConfig: (cfg) => applyKilocodeConfig(cfg), + wizard: { + choiceId: "kilocode-api-key", + choiceLabel: "Kilo Gateway API key", + groupId: "kilocode", + groupLabel: "Kilo Gateway", + groupHint: "API key (OpenRouter-compatible)", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/kilocode/openclaw.plugin.json b/extensions/kilocode/openclaw.plugin.json index 6e3e39aec27..3aa51875bb6 100644 --- a/extensions/kilocode/openclaw.plugin.json +++ b/extensions/kilocode/openclaw.plugin.json @@ -4,6 +4,22 @@ "providerAuthEnvVars": { "kilocode": ["KILOCODE_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "kilocode", + "method": "api-key", + "choiceId": "kilocode-api-key", + "choiceLabel": "Kilo Gateway API key", + "choiceHint": "API key (OpenRouter-compatible)", + "groupId": "kilocode", + "groupLabel": "Kilo Gateway", + "groupHint": "API key (OpenRouter-compatible)", + "optionKey": "kilocodeApiKey", + "cliFlag": "--kilocode-api-key", + "cliOption": "--kilocode-api-key ", + "cliDescription": "Kilo Gateway API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index d6e6e1d74a7..853eee98bef 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -1,5 +1,7 @@ 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"; const PROVIDER_ID = "kimi-coding"; @@ -16,7 +18,33 @@ const kimiCodingPlugin = { aliases: ["kimi-code"], docsPath: "/providers/moonshot", envVars: ["KIMI_API_KEY", "KIMICODE_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Kimi Code API key (subscription)", + hint: "Kimi K2.5 + Kimi Coding", + optionKey: "kimiCodeApiKey", + flagName: "--kimi-code-api-key", + envVar: "KIMI_API_KEY", + promptMessage: "Enter Kimi Coding API key", + defaultModel: KIMI_CODING_MODEL_REF, + expectedProviders: ["kimi-code", "kimi-coding"], + applyConfig: (cfg) => applyKimiCodeConfig(cfg), + noteMessage: [ + "Kimi Coding uses a dedicated endpoint and API key.", + "Get your API key at: https://www.kimi.com/code/en", + ].join("\n"), + noteTitle: "Kimi Coding", + wizard: { + choiceId: "kimi-code-api-key", + choiceLabel: "Kimi Code API key (subscription)", + groupId: "moonshot", + groupLabel: "Moonshot AI (Kimi K2.5)", + groupHint: "Kimi K2.5 + Kimi Coding", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/kimi-coding/openclaw.plugin.json b/extensions/kimi-coding/openclaw.plugin.json index 0664e7ae6df..c86d7211031 100644 --- a/extensions/kimi-coding/openclaw.plugin.json +++ b/extensions/kimi-coding/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "kimi-coding", + "method": "api-key", + "choiceId": "kimi-code-api-key", + "choiceLabel": "Kimi Code API key (subscription)", + "groupId": "moonshot", + "groupLabel": "Moonshot AI (Kimi K2.5)", + "groupHint": "Kimi K2.5 + Kimi Coding", + "optionKey": "kimiCodeApiKey", + "cliFlag": "--kimi-code-api-key", + "cliOption": "--kimi-code-api-key ", + "cliDescription": "Kimi Coding API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 8934580b36b..848ce80699a 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -5,6 +5,56 @@ "minimax": ["MINIMAX_API_KEY"], "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "minimax-portal", + "method": "oauth", + "choiceId": "minimax-global-oauth", + "choiceLabel": "MiniMax OAuth (Global)", + "choiceHint": "Global endpoint - api.minimax.io", + "groupId": "minimax", + "groupLabel": "MiniMax", + "groupHint": "M2.5 (recommended)" + }, + { + "provider": "minimax", + "method": "api-global", + "choiceId": "minimax-global-api", + "choiceLabel": "MiniMax API key (Global)", + "choiceHint": "Global endpoint - api.minimax.io", + "groupId": "minimax", + "groupLabel": "MiniMax", + "groupHint": "M2.5 (recommended)", + "optionKey": "minimaxApiKey", + "cliFlag": "--minimax-api-key", + "cliOption": "--minimax-api-key ", + "cliDescription": "MiniMax API key" + }, + { + "provider": "minimax-portal", + "method": "oauth-cn", + "choiceId": "minimax-cn-oauth", + "choiceLabel": "MiniMax OAuth (CN)", + "choiceHint": "CN endpoint - api.minimaxi.com", + "groupId": "minimax", + "groupLabel": "MiniMax", + "groupHint": "M2.5 (recommended)" + }, + { + "provider": "minimax", + "method": "api-cn", + "choiceId": "minimax-cn-api", + "choiceLabel": "MiniMax API key (CN)", + "choiceHint": "CN endpoint - api.minimaxi.com", + "groupId": "minimax", + "groupLabel": "MiniMax", + "groupHint": "M2.5 (recommended)", + "optionKey": "minimaxApiKey", + "cliFlag": "--minimax-api-key", + "cliOption": "--minimax-api-key ", + "cliDescription": "MiniMax API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index 355c957282b..56e24f8560c 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -1,4 +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"; const PROVIDER_ID = "mistral"; @@ -13,7 +15,28 @@ const mistralPlugin = { label: "Mistral", docsPath: "/providers/models", envVars: ["MISTRAL_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Mistral API key", + hint: "API key", + optionKey: "mistralApiKey", + flagName: "--mistral-api-key", + envVar: "MISTRAL_API_KEY", + promptMessage: "Enter Mistral API key", + defaultModel: MISTRAL_DEFAULT_MODEL_REF, + expectedProviders: ["mistral"], + applyConfig: (cfg) => applyMistralConfig(cfg), + wizard: { + choiceId: "mistral-api-key", + choiceLabel: "Mistral API key", + groupId: "mistral", + groupLabel: "Mistral AI", + groupHint: "API key", + }, + }), + ], capabilities: { transcriptToolCallIdMode: "strict9", transcriptToolCallIdModelHints: [ diff --git a/extensions/mistral/openclaw.plugin.json b/extensions/mistral/openclaw.plugin.json index 480c09417d0..93f115cf719 100644 --- a/extensions/mistral/openclaw.plugin.json +++ b/extensions/mistral/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "mistral": ["MISTRAL_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "mistral", + "method": "api-key", + "choiceId": "mistral-api-key", + "choiceLabel": "Mistral API key", + "groupId": "mistral", + "groupLabel": "Mistral AI", + "groupHint": "API key", + "optionKey": "mistralApiKey", + "cliFlag": "--mistral-api-key", + "cliOption": "--mistral-api-key ", + "cliDescription": "Mistral API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/modelstudio/index.ts b/extensions/modelstudio/index.ts index 487f14498b1..2e3e7c6b3c8 100644 --- a/extensions/modelstudio/index.ts +++ b/extensions/modelstudio/index.ts @@ -1,5 +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"; const PROVIDER_ID = "modelstudio"; @@ -14,7 +20,62 @@ const modelStudioPlugin = { label: "Model Studio", docsPath: "/providers/models", envVars: ["MODELSTUDIO_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key-cn", + label: "Coding Plan API Key for China (subscription)", + hint: "Endpoint: coding.dashscope.aliyuncs.com", + optionKey: "modelstudioApiKeyCn", + flagName: "--modelstudio-api-key-cn", + envVar: "MODELSTUDIO_API_KEY", + promptMessage: "Enter Alibaba Cloud Model Studio Coding Plan API key (China)", + defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF, + expectedProviders: ["modelstudio"], + applyConfig: (cfg) => applyModelStudioConfigCn(cfg), + noteMessage: [ + "Get your API key at: https://bailian.console.aliyun.com/", + "Endpoint: coding.dashscope.aliyuncs.com", + "Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.", + ].join("\n"), + noteTitle: "Alibaba Cloud Model Studio Coding Plan (China)", + wizard: { + choiceId: "modelstudio-api-key-cn", + choiceLabel: "Coding Plan API Key for China (subscription)", + choiceHint: "Endpoint: coding.dashscope.aliyuncs.com", + groupId: "modelstudio", + groupLabel: "Alibaba Cloud Model Studio", + groupHint: "Coding Plan API key (CN / Global)", + }, + }), + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Coding Plan API Key for Global/Intl (subscription)", + hint: "Endpoint: coding-intl.dashscope.aliyuncs.com", + optionKey: "modelstudioApiKey", + flagName: "--modelstudio-api-key", + envVar: "MODELSTUDIO_API_KEY", + promptMessage: "Enter Alibaba Cloud Model Studio Coding Plan API key (Global/Intl)", + defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF, + expectedProviders: ["modelstudio"], + applyConfig: (cfg) => applyModelStudioConfig(cfg), + noteMessage: [ + "Get your API key at: https://bailian.console.aliyun.com/", + "Endpoint: coding-intl.dashscope.aliyuncs.com", + "Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.", + ].join("\n"), + noteTitle: "Alibaba Cloud Model Studio Coding Plan (Global/Intl)", + wizard: { + choiceId: "modelstudio-api-key", + choiceLabel: "Coding Plan API Key for Global/Intl (subscription)", + choiceHint: "Endpoint: coding-intl.dashscope.aliyuncs.com", + groupId: "modelstudio", + groupLabel: "Alibaba Cloud Model Studio", + groupHint: "Coding Plan API key (CN / Global)", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/modelstudio/openclaw.plugin.json b/extensions/modelstudio/openclaw.plugin.json index 5cc87ad1b54..e6c20db50c7 100644 --- a/extensions/modelstudio/openclaw.plugin.json +++ b/extensions/modelstudio/openclaw.plugin.json @@ -4,6 +4,36 @@ "providerAuthEnvVars": { "modelstudio": ["MODELSTUDIO_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "modelstudio", + "method": "api-key-cn", + "choiceId": "modelstudio-api-key-cn", + "choiceLabel": "Coding Plan API Key for China (subscription)", + "choiceHint": "Endpoint: coding.dashscope.aliyuncs.com", + "groupId": "modelstudio", + "groupLabel": "Alibaba Cloud Model Studio", + "groupHint": "Coding Plan API key (CN / Global)", + "optionKey": "modelstudioApiKeyCn", + "cliFlag": "--modelstudio-api-key-cn", + "cliOption": "--modelstudio-api-key-cn ", + "cliDescription": "Alibaba Cloud Model Studio Coding Plan API key (China)" + }, + { + "provider": "modelstudio", + "method": "api-key", + "choiceId": "modelstudio-api-key", + "choiceLabel": "Coding Plan API Key for Global/Intl (subscription)", + "choiceHint": "Endpoint: coding-intl.dashscope.aliyuncs.com", + "groupId": "modelstudio", + "groupLabel": "Alibaba Cloud Model Studio", + "groupHint": "Coding Plan API key (CN / Global)", + "optionKey": "modelstudioApiKey", + "cliFlag": "--modelstudio-api-key", + "cliOption": "--modelstudio-api-key ", + "cliDescription": "Alibaba Cloud Model Studio Coding Plan API key (Global/Intl)" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 44f77d7b56b..3b57a5134ba 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -8,7 +8,13 @@ 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"; const PROVIDER_ID = "moonshot"; @@ -24,7 +30,48 @@ const moonshotPlugin = { label: "Moonshot", docsPath: "/providers/moonshot", envVars: ["MOONSHOT_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Kimi API key (.ai)", + hint: "Kimi K2.5 + Kimi Coding", + optionKey: "moonshotApiKey", + flagName: "--moonshot-api-key", + envVar: "MOONSHOT_API_KEY", + promptMessage: "Enter Moonshot API key", + defaultModel: MOONSHOT_DEFAULT_MODEL_REF, + expectedProviders: ["moonshot"], + applyConfig: (cfg) => applyMoonshotConfig(cfg), + wizard: { + choiceId: "moonshot-api-key", + choiceLabel: "Kimi API key (.ai)", + groupId: "moonshot", + groupLabel: "Moonshot AI (Kimi K2.5)", + groupHint: "Kimi K2.5 + Kimi Coding", + }, + }), + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key-cn", + label: "Kimi API key (.cn)", + hint: "Kimi K2.5 + Kimi Coding", + optionKey: "moonshotApiKey", + flagName: "--moonshot-api-key", + envVar: "MOONSHOT_API_KEY", + promptMessage: "Enter Moonshot API key (.cn)", + defaultModel: MOONSHOT_DEFAULT_MODEL_REF, + expectedProviders: ["moonshot"], + applyConfig: (cfg) => applyMoonshotConfigCn(cfg), + wizard: { + choiceId: "moonshot-api-key-cn", + choiceLabel: "Kimi API key (.cn)", + groupId: "moonshot", + groupLabel: "Moonshot AI (Kimi K2.5)", + groupHint: "Kimi K2.5 + Kimi Coding", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index 542ae46fead..cad9e255a2b 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -4,6 +4,34 @@ "providerAuthEnvVars": { "moonshot": ["MOONSHOT_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "moonshot", + "method": "api-key", + "choiceId": "moonshot-api-key", + "choiceLabel": "Kimi API key (.ai)", + "groupId": "moonshot", + "groupLabel": "Moonshot AI (Kimi K2.5)", + "groupHint": "Kimi K2.5 + Kimi Coding", + "optionKey": "moonshotApiKey", + "cliFlag": "--moonshot-api-key", + "cliOption": "--moonshot-api-key ", + "cliDescription": "Moonshot API key" + }, + { + "provider": "moonshot", + "method": "api-key-cn", + "choiceId": "moonshot-api-key-cn", + "choiceLabel": "Kimi API key (.cn)", + "groupId": "moonshot", + "groupLabel": "Moonshot AI (Kimi K2.5)", + "groupHint": "Kimi K2.5 + Kimi Coding", + "optionKey": "moonshotApiKey", + "cliFlag": "--moonshot-api-key", + "cliOption": "--moonshot-api-key ", + "cliDescription": "Moonshot API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index b644e105b84..bace5f2816f 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -4,6 +4,18 @@ "providerAuthEnvVars": { "ollama": ["OLLAMA_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "ollama", + "method": "local", + "choiceId": "ollama", + "choiceLabel": "Ollama", + "choiceHint": "Cloud and local open models", + "groupId": "ollama", + "groupLabel": "Ollama", + "groupHint": "Cloud and local open models" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 4b0ae0efc31..6a492ec3cae 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -4,6 +4,31 @@ "providerAuthEnvVars": { "openai": ["OPENAI_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "openai-codex", + "method": "oauth", + "choiceId": "openai-codex", + "choiceLabel": "OpenAI Codex (ChatGPT OAuth)", + "choiceHint": "Browser sign-in", + "groupId": "openai", + "groupLabel": "OpenAI", + "groupHint": "Codex OAuth + API key" + }, + { + "provider": "openai", + "method": "api-key", + "choiceId": "openai-api-key", + "choiceLabel": "OpenAI API key", + "groupId": "openai", + "groupLabel": "OpenAI", + "groupHint": "Codex OAuth + API key", + "optionKey": "openaiApiKey", + "cliFlag": "--openai-api-key", + "cliOption": "--openai-api-key ", + "cliDescription": "OpenAI API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index 87e52eab53e..2027f9ad05d 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -1,4 +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"; const PROVIDER_ID = "opencode-go"; @@ -13,7 +16,34 @@ const opencodeGoPlugin = { label: "OpenCode Go", docsPath: "/providers/models", envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "OpenCode Go catalog", + hint: "Shared API key for Zen + Go catalogs", + optionKey: "opencodeGoApiKey", + flagName: "--opencode-go-api-key", + envVar: "OPENCODE_API_KEY", + promptMessage: "Enter OpenCode API key", + defaultModel: OPENCODE_GO_DEFAULT_MODEL_REF, + expectedProviders: ["opencode", "opencode-go"], + applyConfig: (cfg) => applyOpencodeGoConfig(cfg), + noteMessage: [ + "OpenCode uses one API key across the Zen and Go catalogs.", + "Go focuses on Kimi, GLM, and MiniMax coding models.", + "Get your API key at: https://opencode.ai/auth", + ].join("\n"), + noteTitle: "OpenCode", + wizard: { + choiceId: "opencode-go", + choiceLabel: "OpenCode Go catalog", + groupId: "opencode", + groupLabel: "OpenCode", + groupHint: "Shared API key for Zen + Go catalogs", + }, + }), + ], capabilities: { openAiCompatTurnValidation: false, geminiThoughtSignatureSanitization: true, diff --git a/extensions/opencode-go/openclaw.plugin.json b/extensions/opencode-go/openclaw.plugin.json index d264f4acdb6..9972dc633ff 100644 --- a/extensions/opencode-go/openclaw.plugin.json +++ b/extensions/opencode-go/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "opencode-go", + "method": "api-key", + "choiceId": "opencode-go", + "choiceLabel": "OpenCode Go catalog", + "groupId": "opencode", + "groupLabel": "OpenCode", + "groupHint": "Shared API key for Zen + Go catalogs", + "optionKey": "opencodeGoApiKey", + "cliFlag": "--opencode-go-api-key", + "cliOption": "--opencode-go-api-key ", + "cliDescription": "OpenCode API key (Go catalog)" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index c800961ab36..008afe4091c 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -1,4 +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"; const PROVIDER_ID = "opencode"; const MINIMAX_PREFIX = "minimax-m2.5"; @@ -22,7 +25,35 @@ const opencodePlugin = { label: "OpenCode Zen", docsPath: "/providers/models", envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "OpenCode Zen catalog", + hint: "Shared API key for Zen + Go catalogs", + optionKey: "opencodeZenApiKey", + flagName: "--opencode-zen-api-key", + envVar: "OPENCODE_API_KEY", + promptMessage: "Enter OpenCode API key", + defaultModel: OPENCODE_ZEN_DEFAULT_MODEL, + expectedProviders: ["opencode", "opencode-go"], + applyConfig: (cfg) => applyOpencodeZenConfig(cfg), + noteMessage: [ + "OpenCode uses one API key across the Zen and Go catalogs.", + "Zen provides access to Claude, GPT, Gemini, and more models.", + "Get your API key at: https://opencode.ai/auth", + "Choose the Zen catalog when you want the curated multi-model proxy.", + ].join("\n"), + noteTitle: "OpenCode", + wizard: { + choiceId: "opencode-zen", + choiceLabel: "OpenCode Zen catalog", + groupId: "opencode", + groupLabel: "OpenCode", + groupHint: "Shared API key for Zen + Go catalogs", + }, + }), + ], capabilities: { openAiCompatTurnValidation: false, geminiThoughtSignatureSanitization: true, diff --git a/extensions/opencode/openclaw.plugin.json b/extensions/opencode/openclaw.plugin.json index 68608e6abd1..9352896340b 100644 --- a/extensions/opencode/openclaw.plugin.json +++ b/extensions/opencode/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "opencode": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "opencode", + "method": "api-key", + "choiceId": "opencode-zen", + "choiceLabel": "OpenCode Zen catalog", + "groupId": "opencode", + "groupLabel": "OpenCode", + "groupHint": "Shared API key for Zen + Go catalogs", + "optionKey": "opencodeZenApiKey", + "cliFlag": "--opencode-zen-api-key", + "cliOption": "--opencode-zen-api-key ", + "cliDescription": "OpenCode API key (Zen catalog)" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index 92521cb3984..ec4afaa873c 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -16,6 +16,11 @@ 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"; const PROVIDER_ID = "openrouter"; const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; @@ -85,7 +90,28 @@ const openRouterPlugin = { label: "OpenRouter", docsPath: "/providers/models", envVars: ["OPENROUTER_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "OpenRouter API key", + hint: "API key", + optionKey: "openrouterApiKey", + flagName: "--openrouter-api-key", + envVar: "OPENROUTER_API_KEY", + promptMessage: "Enter OpenRouter API key", + defaultModel: OPENROUTER_DEFAULT_MODEL_REF, + expectedProviders: ["openrouter"], + applyConfig: (cfg) => applyOpenrouterConfig(cfg), + wizard: { + choiceId: "openrouter-api-key", + choiceLabel: "OpenRouter API key", + groupId: "openrouter", + groupLabel: "OpenRouter", + groupHint: "API key", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/openrouter/openclaw.plugin.json b/extensions/openrouter/openclaw.plugin.json index 84069b8129b..8151f24e677 100644 --- a/extensions/openrouter/openclaw.plugin.json +++ b/extensions/openrouter/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "openrouter": ["OPENROUTER_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "openrouter", + "method": "api-key", + "choiceId": "openrouter-api-key", + "choiceLabel": "OpenRouter API key", + "groupId": "openrouter", + "groupLabel": "OpenRouter", + "groupHint": "API key", + "optionKey": "openrouterApiKey", + "cliFlag": "--openrouter-api-key", + "cliOption": "--openrouter-api-key ", + "cliDescription": "OpenRouter API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/qianfan/index.ts b/extensions/qianfan/index.ts index 1da228d3772..88b5fee122d 100644 --- a/extensions/qianfan/index.ts +++ b/extensions/qianfan/index.ts @@ -1,5 +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"; const PROVIDER_ID = "qianfan"; @@ -14,7 +16,28 @@ const qianfanPlugin = { label: "Qianfan", docsPath: "/providers/qianfan", envVars: ["QIANFAN_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Qianfan API key", + hint: "API key", + optionKey: "qianfanApiKey", + flagName: "--qianfan-api-key", + envVar: "QIANFAN_API_KEY", + promptMessage: "Enter Qianfan API key", + defaultModel: QIANFAN_DEFAULT_MODEL_REF, + expectedProviders: ["qianfan"], + applyConfig: (cfg) => applyQianfanConfig(cfg), + wizard: { + choiceId: "qianfan-api-key", + choiceLabel: "Qianfan API key", + groupId: "qianfan", + groupLabel: "Qianfan", + groupHint: "API key", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/qianfan/openclaw.plugin.json b/extensions/qianfan/openclaw.plugin.json index 5070b7a65b7..d2ac243e261 100644 --- a/extensions/qianfan/openclaw.plugin.json +++ b/extensions/qianfan/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "qianfan": ["QIANFAN_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "qianfan", + "method": "api-key", + "choiceId": "qianfan-api-key", + "choiceLabel": "Qianfan API key", + "groupId": "qianfan", + "groupLabel": "Qianfan", + "groupHint": "API key", + "optionKey": "qianfanApiKey", + "cliFlag": "--qianfan-api-key", + "cliOption": "--qianfan-api-key ", + "cliDescription": "QIANFAN API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/qwen-portal-auth/openclaw.plugin.json b/extensions/qwen-portal-auth/openclaw.plugin.json index 1f5a5deb0b5..5a6a8d555b7 100644 --- a/extensions/qwen-portal-auth/openclaw.plugin.json +++ b/extensions/qwen-portal-auth/openclaw.plugin.json @@ -4,6 +4,18 @@ "providerAuthEnvVars": { "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "qwen-portal", + "method": "device", + "choiceId": "qwen-portal", + "choiceLabel": "Qwen OAuth", + "choiceHint": "Device code login", + "groupId": "qwen", + "groupLabel": "Qwen", + "groupHint": "OAuth" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/sglang/openclaw.plugin.json b/extensions/sglang/openclaw.plugin.json index 8d5840c0fdf..d9f7f92eb52 100644 --- a/extensions/sglang/openclaw.plugin.json +++ b/extensions/sglang/openclaw.plugin.json @@ -4,6 +4,18 @@ "providerAuthEnvVars": { "sglang": ["SGLANG_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "sglang", + "method": "custom", + "choiceId": "sglang", + "choiceLabel": "SGLang", + "choiceHint": "Fast self-hosted OpenAI-compatible server", + "groupId": "sglang", + "groupLabel": "SGLang", + "groupHint": "Fast self-hosted server" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/synthetic/index.ts b/extensions/synthetic/index.ts index c22dcc11f8b..080245606da 100644 --- a/extensions/synthetic/index.ts +++ b/extensions/synthetic/index.ts @@ -1,5 +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"; const PROVIDER_ID = "synthetic"; @@ -14,7 +19,28 @@ const syntheticPlugin = { label: "Synthetic", docsPath: "/providers/synthetic", envVars: ["SYNTHETIC_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Synthetic API key", + hint: "Anthropic-compatible (multi-model)", + optionKey: "syntheticApiKey", + flagName: "--synthetic-api-key", + envVar: "SYNTHETIC_API_KEY", + promptMessage: "Enter Synthetic API key", + defaultModel: SYNTHETIC_DEFAULT_MODEL_REF, + expectedProviders: ["synthetic"], + applyConfig: (cfg) => applySyntheticConfig(cfg), + wizard: { + choiceId: "synthetic-api-key", + choiceLabel: "Synthetic API key", + groupId: "synthetic", + groupLabel: "Synthetic", + groupHint: "Anthropic-compatible (multi-model)", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/synthetic/openclaw.plugin.json b/extensions/synthetic/openclaw.plugin.json index 54c12a19e4c..a0c519a72b2 100644 --- a/extensions/synthetic/openclaw.plugin.json +++ b/extensions/synthetic/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "synthetic": ["SYNTHETIC_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "synthetic", + "method": "api-key", + "choiceId": "synthetic-api-key", + "choiceLabel": "Synthetic API key", + "groupId": "synthetic", + "groupLabel": "Synthetic", + "groupHint": "Anthropic-compatible (multi-model)", + "optionKey": "syntheticApiKey", + "cliFlag": "--synthetic-api-key", + "cliOption": "--synthetic-api-key ", + "cliDescription": "Synthetic API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/together/index.ts b/extensions/together/index.ts index 5b1f6ced62f..7408fbea140 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -1,5 +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"; const PROVIDER_ID = "together"; @@ -14,7 +19,28 @@ const togetherPlugin = { label: "Together", docsPath: "/providers/together", envVars: ["TOGETHER_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Together AI API key", + hint: "API key", + optionKey: "togetherApiKey", + flagName: "--together-api-key", + envVar: "TOGETHER_API_KEY", + promptMessage: "Enter Together AI API key", + defaultModel: TOGETHER_DEFAULT_MODEL_REF, + expectedProviders: ["together"], + applyConfig: (cfg) => applyTogetherConfig(cfg), + wizard: { + choiceId: "together-api-key", + choiceLabel: "Together AI API key", + groupId: "together", + groupLabel: "Together AI", + groupHint: "API key", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/together/openclaw.plugin.json b/extensions/together/openclaw.plugin.json index ea3ae237fa2..f185492aa54 100644 --- a/extensions/together/openclaw.plugin.json +++ b/extensions/together/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "together": ["TOGETHER_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "together", + "method": "api-key", + "choiceId": "together-api-key", + "choiceLabel": "Together AI API key", + "groupId": "together", + "groupLabel": "Together AI", + "groupHint": "API key", + "optionKey": "togetherApiKey", + "cliFlag": "--together-api-key", + "cliOption": "--together-api-key ", + "cliDescription": "Together AI API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index 75cd6adbaf1..12714fc2666 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -1,5 +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"; const PROVIDER_ID = "venice"; @@ -14,7 +16,34 @@ const venicePlugin = { label: "Venice", docsPath: "/providers/venice", envVars: ["VENICE_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Venice AI API key", + hint: "Privacy-focused (uncensored models)", + optionKey: "veniceApiKey", + flagName: "--venice-api-key", + envVar: "VENICE_API_KEY", + promptMessage: "Enter Venice AI API key", + defaultModel: VENICE_DEFAULT_MODEL_REF, + expectedProviders: ["venice"], + applyConfig: (cfg) => applyVeniceConfig(cfg), + noteMessage: [ + "Venice AI provides privacy-focused inference with uncensored models.", + "Get your API key at: https://venice.ai/settings/api", + "Supports 'private' (fully private) and 'anonymized' (proxy) modes.", + ].join("\n"), + noteTitle: "Venice AI", + wizard: { + choiceId: "venice-api-key", + choiceLabel: "Venice AI API key", + groupId: "venice", + groupLabel: "Venice AI", + groupHint: "Privacy-focused (uncensored models)", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/venice/openclaw.plugin.json b/extensions/venice/openclaw.plugin.json index a84a0e7b669..6667907b5e4 100644 --- a/extensions/venice/openclaw.plugin.json +++ b/extensions/venice/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "venice": ["VENICE_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "venice", + "method": "api-key", + "choiceId": "venice-api-key", + "choiceLabel": "Venice AI API key", + "groupId": "venice", + "groupLabel": "Venice AI", + "groupHint": "Privacy-focused (uncensored models)", + "optionKey": "veniceApiKey", + "cliFlag": "--venice-api-key", + "cliOption": "--venice-api-key ", + "cliDescription": "Venice API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index c3098130f3e..a656cf400a7 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -1,5 +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"; const PROVIDER_ID = "vercel-ai-gateway"; @@ -14,7 +19,28 @@ const vercelAiGatewayPlugin = { label: "Vercel AI Gateway", docsPath: "/providers/vercel-ai-gateway", envVars: ["AI_GATEWAY_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Vercel AI Gateway API key", + hint: "API key", + optionKey: "aiGatewayApiKey", + flagName: "--ai-gateway-api-key", + envVar: "AI_GATEWAY_API_KEY", + promptMessage: "Enter Vercel AI Gateway API key", + defaultModel: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + expectedProviders: ["vercel-ai-gateway"], + applyConfig: (cfg) => applyVercelAiGatewayConfig(cfg), + wizard: { + choiceId: "ai-gateway-api-key", + choiceLabel: "Vercel AI Gateway API key", + groupId: "ai-gateway", + groupLabel: "Vercel AI Gateway", + groupHint: "API key", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/vercel-ai-gateway/openclaw.plugin.json b/extensions/vercel-ai-gateway/openclaw.plugin.json index 47037724c36..e86c3943c9a 100644 --- a/extensions/vercel-ai-gateway/openclaw.plugin.json +++ b/extensions/vercel-ai-gateway/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "vercel-ai-gateway", + "method": "api-key", + "choiceId": "ai-gateway-api-key", + "choiceLabel": "Vercel AI Gateway API key", + "groupId": "ai-gateway", + "groupLabel": "Vercel AI Gateway", + "groupHint": "API key", + "optionKey": "aiGatewayApiKey", + "cliFlag": "--ai-gateway-api-key", + "cliOption": "--ai-gateway-api-key ", + "cliDescription": "Vercel AI Gateway API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/vllm/openclaw.plugin.json b/extensions/vllm/openclaw.plugin.json index 6ab01cb5e89..bc3ad68c407 100644 --- a/extensions/vllm/openclaw.plugin.json +++ b/extensions/vllm/openclaw.plugin.json @@ -4,6 +4,18 @@ "providerAuthEnvVars": { "vllm": ["VLLM_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "vllm", + "method": "custom", + "choiceId": "vllm", + "choiceLabel": "vLLM", + "choiceHint": "Local/self-hosted OpenAI-compatible server", + "groupId": "vllm", + "groupLabel": "vLLM", + "groupHint": "Local/self-hosted OpenAI-compatible" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/volcengine/index.ts b/extensions/volcengine/index.ts index 7d907b5f53e..2e6063365df 100644 --- a/extensions/volcengine/index.ts +++ b/extensions/volcengine/index.ts @@ -3,8 +3,11 @@ 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"; const PROVIDER_ID = "volcengine"; +const VOLCENGINE_DEFAULT_MODEL_REF = "volcengine-plan/ark-code-latest"; const volcenginePlugin = { id: PROVIDER_ID, @@ -17,7 +20,32 @@ const volcenginePlugin = { label: "Volcengine", docsPath: "/concepts/model-providers#volcano-engine-doubao", envVars: ["VOLCANO_ENGINE_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Volcano Engine API key", + hint: "API key", + optionKey: "volcengineApiKey", + flagName: "--volcengine-api-key", + envVar: "VOLCANO_ENGINE_API_KEY", + promptMessage: "Enter Volcano Engine API key", + defaultModel: VOLCENGINE_DEFAULT_MODEL_REF, + expectedProviders: ["volcengine"], + applyConfig: (cfg) => + ensureModelAllowlistEntry({ + cfg, + modelRef: VOLCENGINE_DEFAULT_MODEL_REF, + }), + wizard: { + choiceId: "volcengine-api-key", + choiceLabel: "Volcano Engine API key", + groupId: "volcengine", + groupLabel: "Volcano Engine", + groupHint: "API key", + }, + }), + ], catalog: { order: "paired", run: async (ctx) => { diff --git a/extensions/volcengine/openclaw.plugin.json b/extensions/volcengine/openclaw.plugin.json index 2b5e54ff013..4fd6d192281 100644 --- a/extensions/volcengine/openclaw.plugin.json +++ b/extensions/volcengine/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "volcengine": ["VOLCANO_ENGINE_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "volcengine", + "method": "api-key", + "choiceId": "volcengine-api-key", + "choiceLabel": "Volcano Engine API key", + "groupId": "volcengine", + "groupLabel": "Volcano Engine", + "groupHint": "API key", + "optionKey": "volcengineApiKey", + "cliFlag": "--volcengine-api-key", + "cliOption": "--volcengine-api-key ", + "cliDescription": "Volcano Engine API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index f2ef8b30605..98731023653 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -4,9 +4,12 @@ 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"; +const PROVIDER_ID = "xai"; const XAI_MODERN_MODEL_PREFIXES = ["grok-4"] as const; function matchesModernXaiModel(modelId: string): boolean { @@ -21,11 +24,32 @@ const xaiPlugin = { configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerProvider({ - id: "xai", + id: PROVIDER_ID, label: "xAI", docsPath: "/providers/models", envVars: ["XAI_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "xAI API key", + hint: "API key", + optionKey: "xaiApiKey", + flagName: "--xai-api-key", + envVar: "XAI_API_KEY", + promptMessage: "Enter xAI API key", + defaultModel: XAI_DEFAULT_MODEL_REF, + expectedProviders: ["xai"], + applyConfig: (cfg) => applyXaiConfig(cfg), + wizard: { + choiceId: "xai-api-key", + choiceLabel: "xAI API key", + groupId: "xai", + groupLabel: "xAI (Grok)", + groupHint: "API key", + }, + }), + ], isModernModelRef: ({ provider, modelId }) => normalizeProviderId(provider) === "xai" ? matchesModernXaiModel(modelId) : undefined, }); diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index eca25c99403..8cb2d8f5cfc 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "xai": ["XAI_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "xai", + "method": "api-key", + "choiceId": "xai-api-key", + "choiceLabel": "xAI API key", + "groupId": "xai", + "groupLabel": "xAI (Grok)", + "groupHint": "API key", + "optionKey": "xaiApiKey", + "cliFlag": "--xai-api-key", + "cliOption": "--xai-api-key ", + "cliDescription": "xAI API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index 37d7d799691..4987b18c8fd 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -1,6 +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"; const PROVIDER_ID = "xiaomi"; @@ -15,7 +17,28 @@ const xiaomiPlugin = { label: "Xiaomi", docsPath: "/providers/xiaomi", envVars: ["XIAOMI_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Xiaomi API key", + hint: "API key", + optionKey: "xiaomiApiKey", + flagName: "--xiaomi-api-key", + envVar: "XIAOMI_API_KEY", + promptMessage: "Enter Xiaomi API key", + defaultModel: XIAOMI_DEFAULT_MODEL_REF, + expectedProviders: ["xiaomi"], + applyConfig: (cfg) => applyXiaomiConfig(cfg), + wizard: { + choiceId: "xiaomi-api-key", + choiceLabel: "Xiaomi API key", + groupId: "xiaomi", + groupLabel: "Xiaomi", + groupHint: "API key", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/xiaomi/openclaw.plugin.json b/extensions/xiaomi/openclaw.plugin.json index 4f0c03c280f..61bec4e1473 100644 --- a/extensions/xiaomi/openclaw.plugin.json +++ b/extensions/xiaomi/openclaw.plugin.json @@ -4,6 +4,21 @@ "providerAuthEnvVars": { "xiaomi": ["XIAOMI_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "xiaomi", + "method": "api-key", + "choiceId": "xiaomi-api-key", + "choiceLabel": "Xiaomi API key", + "groupId": "xiaomi", + "groupLabel": "Xiaomi", + "groupHint": "API key", + "optionKey": "xiaomiApiKey", + "cliFlag": "--xiaomi-api-key", + "cliOption": "--xiaomi-api-key ", + "cliDescription": "Xiaomi API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index f4fd60ad5c3..d6a1561167d 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -4,18 +4,38 @@ import path from "node:path"; import { emptyPluginConfigSchema, type OpenClawPluginApi, + type ProviderAuthContext, + type ProviderAuthMethod, + type ProviderAuthMethodNonInteractiveContext, 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 { + 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/onboard-auth.credentials.js"; +import { + applyAuthProfileConfig, + applyZaiConfig, + applyZaiProviderConfig, + ZAI_DEFAULT_MODEL_REF, +} from "../../src/commands/onboard-auth.js"; +import { detectZaiEndpoint, type ZaiEndpointId } from "../../src/commands/zai-endpoint-detect.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"; const PROVIDER_ID = "zai"; const GLM5_MODEL_ID = "glm-5"; const GLM5_TEMPLATE_MODEL_ID = "glm-4.7"; +const PROFILE_ID = "zai:default"; function resolveGlm5ForwardCompatModel( ctx: ProviderResolveDynamicModelContext, @@ -73,6 +93,144 @@ function resolveLegacyZaiUsageToken(env: NodeJS.ProcessEnv): string | undefined } } +function resolveZaiDefaultModel(modelIdOverride?: string): string { + return modelIdOverride ? `zai/${modelIdOverride}` : ZAI_DEFAULT_MODEL_REF; +} + +async function runZaiApiKeyAuth( + ctx: ProviderAuthContext, + endpoint?: ZaiEndpointId, +): Promise<{ + profiles: Array<{ profileId: string; credential: ReturnType }>; + configPatch: ReturnType; + defaultModel: string; + notes?: string[]; +}> { + let capturedSecretInput: SecretInput | undefined; + let capturedCredential = false; + let capturedMode: "plaintext" | "ref" | undefined; + const apiKey = await ensureApiKeyFromOptionEnvOrPrompt({ + token: + normalizeOptionalSecretInput(ctx.opts?.zaiApiKey) ?? + normalizeOptionalSecretInput(ctx.opts?.token), + tokenProvider: normalizeOptionalSecretInput(ctx.opts?.zaiApiKey) + ? PROVIDER_ID + : normalizeOptionalSecretInput(ctx.opts?.tokenProvider), + secretInputMode: ctx.secretInputMode, + config: ctx.config, + expectedProviders: [PROVIDER_ID, "z-ai"], + provider: PROVIDER_ID, + envLabel: "ZAI_API_KEY", + promptMessage: "Enter Z.AI API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: ctx.prompter, + setCredential: async (key, mode) => { + capturedSecretInput = key; + capturedCredential = true; + capturedMode = mode; + }, + }); + if (!capturedCredential) { + throw new Error("Missing Z.AI API key."); + } + const credentialInput = capturedSecretInput ?? ""; + + const detected = await detectZaiEndpoint({ apiKey, ...(endpoint ? { endpoint } : {}) }); + const modelIdOverride = detected?.modelId; + const nextEndpoint = detected?.endpoint ?? endpoint; + return { + profiles: [ + { + profileId: PROFILE_ID, + credential: buildApiKeyCredential( + PROVIDER_ID, + credentialInput, + undefined, + capturedMode ? { secretInputMode: capturedMode } : undefined, + ), + }, + ], + configPatch: applyZaiProviderConfig(ctx.config, { + ...(nextEndpoint ? { endpoint: nextEndpoint } : {}), + ...(modelIdOverride ? { modelId: modelIdOverride } : {}), + }), + defaultModel: resolveZaiDefaultModel(modelIdOverride), + ...(detected?.note ? { notes: [detected.note] } : {}), + }; +} + +async function runZaiApiKeyAuthNonInteractive( + ctx: ProviderAuthMethodNonInteractiveContext, + endpoint?: ZaiEndpointId, +) { + const resolved = await ctx.resolveApiKey({ + provider: PROVIDER_ID, + flagValue: normalizeOptionalSecretInput(ctx.opts.zaiApiKey), + flagName: "--zai-api-key", + envVar: "ZAI_API_KEY", + }); + if (!resolved) { + return null; + } + const detected = await detectZaiEndpoint({ + apiKey: resolved.key, + ...(endpoint ? { endpoint } : {}), + }); + const modelIdOverride = detected?.modelId; + const nextEndpoint = detected?.endpoint ?? endpoint; + + if (resolved.source !== "profile") { + const credential = ctx.toApiKeyCredential({ + provider: PROVIDER_ID, + resolved, + }); + if (!credential) { + return null; + } + upsertAuthProfile({ + profileId: PROFILE_ID, + credential, + agentDir: ctx.agentDir, + }); + } + + const next = applyAuthProfileConfig(ctx.config, { + profileId: PROFILE_ID, + provider: PROVIDER_ID, + mode: "api_key", + }); + return applyZaiConfig(next, { + ...(nextEndpoint ? { endpoint: nextEndpoint } : {}), + ...(modelIdOverride ? { modelId: modelIdOverride } : {}), + }); +} + +function buildZaiApiKeyMethod(params: { + id: string; + choiceId: string; + choiceLabel: string; + choiceHint?: string; + endpoint?: ZaiEndpointId; +}): ProviderAuthMethod { + return { + id: params.id, + label: params.choiceLabel, + hint: params.choiceHint, + kind: "api_key", + wizard: { + choiceId: params.choiceId, + choiceLabel: params.choiceLabel, + ...(params.choiceHint ? { choiceHint: params.choiceHint } : {}), + groupId: "zai", + groupLabel: "Z.AI", + groupHint: "GLM Coding Plan / Global / CN", + }, + run: async (ctx) => await runZaiApiKeyAuth(ctx, params.endpoint), + runNonInteractive: async (ctx) => await runZaiApiKeyAuthNonInteractive(ctx, params.endpoint), + }; +} + const zaiPlugin = { id: PROVIDER_ID, name: "Z.AI Provider", @@ -85,7 +243,41 @@ const zaiPlugin = { aliases: ["z-ai", "z.ai"], docsPath: "/providers/models", envVars: ["ZAI_API_KEY", "Z_AI_API_KEY"], - auth: [], + auth: [ + buildZaiApiKeyMethod({ + id: "api-key", + choiceId: "zai-api-key", + choiceLabel: "Z.AI API key", + }), + buildZaiApiKeyMethod({ + id: "coding-global", + choiceId: "zai-coding-global", + choiceLabel: "Coding-Plan-Global", + choiceHint: "GLM Coding Plan Global (api.z.ai)", + endpoint: "coding-global", + }), + buildZaiApiKeyMethod({ + id: "coding-cn", + choiceId: "zai-coding-cn", + choiceLabel: "Coding-Plan-CN", + choiceHint: "GLM Coding Plan CN (open.bigmodel.cn)", + endpoint: "coding-cn", + }), + buildZaiApiKeyMethod({ + id: "global", + choiceId: "zai-global", + choiceLabel: "Global", + choiceHint: "Z.AI Global (api.z.ai)", + endpoint: "global", + }), + buildZaiApiKeyMethod({ + id: "cn", + choiceId: "zai-cn", + choiceLabel: "CN", + choiceHint: "Z.AI CN (open.bigmodel.cn)", + endpoint: "cn", + }), + ], resolveDynamicModel: (ctx) => resolveGlm5ForwardCompatModel(ctx), prepareExtraParams: (ctx) => { if (ctx.extraParams?.tool_stream !== undefined) { diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json index c5985d748b0..2a7e1c8b40a 100644 --- a/extensions/zai/openclaw.plugin.json +++ b/extensions/zai/openclaw.plugin.json @@ -4,6 +4,77 @@ "providerAuthEnvVars": { "zai": ["ZAI_API_KEY", "Z_AI_API_KEY"] }, + "providerAuthChoices": [ + { + "provider": "zai", + "method": "api-key", + "choiceId": "zai-api-key", + "choiceLabel": "Z.AI API key", + "groupId": "zai", + "groupLabel": "Z.AI", + "groupHint": "GLM Coding Plan / Global / CN", + "optionKey": "zaiApiKey", + "cliFlag": "--zai-api-key", + "cliOption": "--zai-api-key ", + "cliDescription": "Z.AI API key" + }, + { + "provider": "zai", + "method": "coding-global", + "choiceId": "zai-coding-global", + "choiceLabel": "Coding-Plan-Global", + "choiceHint": "GLM Coding Plan Global (api.z.ai)", + "groupId": "zai", + "groupLabel": "Z.AI", + "groupHint": "GLM Coding Plan / Global / CN", + "optionKey": "zaiApiKey", + "cliFlag": "--zai-api-key", + "cliOption": "--zai-api-key ", + "cliDescription": "Z.AI API key" + }, + { + "provider": "zai", + "method": "coding-cn", + "choiceId": "zai-coding-cn", + "choiceLabel": "Coding-Plan-CN", + "choiceHint": "GLM Coding Plan CN (open.bigmodel.cn)", + "groupId": "zai", + "groupLabel": "Z.AI", + "groupHint": "GLM Coding Plan / Global / CN", + "optionKey": "zaiApiKey", + "cliFlag": "--zai-api-key", + "cliOption": "--zai-api-key ", + "cliDescription": "Z.AI API key" + }, + { + "provider": "zai", + "method": "global", + "choiceId": "zai-global", + "choiceLabel": "Global", + "choiceHint": "Z.AI Global (api.z.ai)", + "groupId": "zai", + "groupLabel": "Z.AI", + "groupHint": "GLM Coding Plan / Global / CN", + "optionKey": "zaiApiKey", + "cliFlag": "--zai-api-key", + "cliOption": "--zai-api-key ", + "cliDescription": "Z.AI API key" + }, + { + "provider": "zai", + "method": "cn", + "choiceId": "zai-cn", + "choiceLabel": "CN", + "choiceHint": "Z.AI CN (open.bigmodel.cn)", + "groupId": "zai", + "groupLabel": "Z.AI", + "groupHint": "GLM Coding Plan / Global / CN", + "optionKey": "zaiApiKey", + "cliFlag": "--zai-api-key", + "cliOption": "--zai-api-key ", + "cliDescription": "Z.AI API key" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/src/cli/program/register.onboard.test.ts b/src/cli/program/register.onboard.test.ts index def5f28d12c..5b005512bfd 100644 --- a/src/cli/program/register.onboard.test.ts +++ b/src/cli/program/register.onboard.test.ts @@ -13,13 +13,28 @@ vi.mock("../../commands/auth-choice-options.static.js", () => ({ formatStaticAuthChoiceChoicesForCli: () => "token|oauth", })); -vi.mock("../../commands/onboard-provider-auth-flags.js", () => ({ - ONBOARD_PROVIDER_AUTH_FLAGS: [ +vi.mock("../../commands/auth-choice-options.js", () => ({ + formatAuthChoiceChoicesForCli: () => "token|oauth|openai-api-key", +})); + +vi.mock("../../commands/onboard-core-auth-flags.js", () => ({ + CORE_ONBOARD_AUTH_FLAGS: [ { cliOption: "--mistral-api-key ", description: "Mistral API key", + optionKey: "mistralApiKey", }, - ] as Array<{ cliOption: string; description: string }>, + ] as Array<{ cliOption: string; description: string; optionKey: string }>, +})); + +vi.mock("../../plugins/provider-auth-choices.js", () => ({ + resolveManifestProviderOnboardAuthFlags: () => [ + { + cliOption: "--openai-api-key ", + description: "OpenAI API key", + optionKey: "openaiApiKey", + }, + ], })); vi.mock("../../commands/onboard.js", () => ({ diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 914732e4079..0cd2828553b 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; -import { formatStaticAuthChoiceChoicesForCli } from "../../commands/auth-choice-options.static.js"; +import { formatAuthChoiceChoicesForCli } from "../../commands/auth-choice-options.js"; import type { GatewayDaemonRuntime } from "../../commands/daemon-runtime.js"; -import { ONBOARD_PROVIDER_AUTH_FLAGS } from "../../commands/onboard-provider-auth-flags.js"; +import { CORE_ONBOARD_AUTH_FLAGS } from "../../commands/onboard-core-auth-flags.js"; import type { AuthChoice, GatewayAuthChoice, @@ -12,6 +12,7 @@ import type { TailscaleMode, } from "../../commands/onboard-types.js"; import { setupWizardCommand } from "../../commands/onboard.js"; +import { resolveManifestProviderOnboardAuthFlags } from "../../plugins/provider-auth-choices.js"; import { defaultRuntime } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; @@ -41,11 +42,24 @@ function resolveInstallDaemonFlag( return undefined; } -const AUTH_CHOICE_HELP = formatStaticAuthChoiceChoicesForCli({ +const AUTH_CHOICE_HELP = formatAuthChoiceChoicesForCli({ includeLegacyAliases: true, includeSkip: true, }); +const ONBOARD_AUTH_FLAGS = [ + ...CORE_ONBOARD_AUTH_FLAGS, + ...resolveManifestProviderOnboardAuthFlags(), +] as const; + +function pickOnboardProviderAuthOptionValues( + opts: Record, +): Partial> { + return Object.fromEntries( + ONBOARD_AUTH_FLAGS.map((flag) => [flag.optionKey, opts[flag.optionKey] as string | undefined]), + ); +} + export function registerOnboardCommand(program: Command) { const command = program .command("onboard") @@ -87,7 +101,7 @@ export function registerOnboardCommand(program: Command) { .option("--cloudflare-ai-gateway-account-id ", "Cloudflare Account ID") .option("--cloudflare-ai-gateway-gateway-id ", "Cloudflare AI Gateway ID"); - for (const providerFlag of ONBOARD_PROVIDER_AUTH_FLAGS) { + for (const providerFlag of ONBOARD_AUTH_FLAGS) { command.option(providerFlag.cliOption, providerFlag.description); } @@ -132,6 +146,9 @@ export function registerOnboardCommand(program: Command) { }); const gatewayPort = typeof opts.gatewayPort === "string" ? Number.parseInt(opts.gatewayPort, 10) : undefined; + const providerAuthOptionValues = pickOnboardProviderAuthOptionValues( + opts as Record, + ); await setupWizardCommand( { workspace: opts.workspace as string | undefined, @@ -145,34 +162,9 @@ export function registerOnboardCommand(program: Command) { tokenProfileId: opts.tokenProfileId as string | undefined, tokenExpiresIn: opts.tokenExpiresIn as string | undefined, secretInputMode: opts.secretInputMode as SecretInputMode | undefined, - anthropicApiKey: opts.anthropicApiKey as string | undefined, - openaiApiKey: opts.openaiApiKey as string | undefined, - mistralApiKey: opts.mistralApiKey as string | undefined, - openrouterApiKey: opts.openrouterApiKey as string | undefined, - kilocodeApiKey: opts.kilocodeApiKey as string | undefined, - aiGatewayApiKey: opts.aiGatewayApiKey as string | undefined, + ...providerAuthOptionValues, cloudflareAiGatewayAccountId: opts.cloudflareAiGatewayAccountId as string | undefined, cloudflareAiGatewayGatewayId: opts.cloudflareAiGatewayGatewayId as string | undefined, - cloudflareAiGatewayApiKey: opts.cloudflareAiGatewayApiKey as string | undefined, - moonshotApiKey: opts.moonshotApiKey as string | undefined, - kimiCodeApiKey: opts.kimiCodeApiKey as string | undefined, - geminiApiKey: opts.geminiApiKey as string | undefined, - zaiApiKey: opts.zaiApiKey as string | undefined, - xiaomiApiKey: opts.xiaomiApiKey as string | undefined, - qianfanApiKey: opts.qianfanApiKey as string | undefined, - modelstudioApiKeyCn: opts.modelstudioApiKeyCn as string | undefined, - modelstudioApiKey: opts.modelstudioApiKey as string | undefined, - minimaxApiKey: opts.minimaxApiKey as string | undefined, - syntheticApiKey: opts.syntheticApiKey as string | undefined, - veniceApiKey: opts.veniceApiKey as string | undefined, - togetherApiKey: opts.togetherApiKey as string | undefined, - huggingfaceApiKey: opts.huggingfaceApiKey as string | undefined, - opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined, - opencodeGoApiKey: opts.opencodeGoApiKey as string | undefined, - xaiApiKey: opts.xaiApiKey as string | undefined, - litellmApiKey: opts.litellmApiKey as string | undefined, - volcengineApiKey: opts.volcengineApiKey as string | undefined, - byteplusApiKey: opts.byteplusApiKey as string | undefined, customBaseUrl: opts.customBaseUrl as string | undefined, customApiKey: opts.customApiKey as string | undefined, customModelId: opts.customModelId as string | undefined, diff --git a/src/commands/auth-choice-options.static.ts b/src/commands/auth-choice-options.static.ts index f42c208333f..2dd92e35cdf 100644 --- a/src/commands/auth-choice-options.static.ts +++ b/src/commands/auth-choice-options.static.ts @@ -1,5 +1,4 @@ import { AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI } from "./auth-choice-legacy.js"; -import { ONBOARD_PROVIDER_AUTH_FLAGS } from "./onboard-provider-auth-flags.js"; import type { AuthChoice, AuthChoiceGroupId } from "./onboard-types.js"; export type { AuthChoiceGroupId }; @@ -8,7 +7,11 @@ export type AuthChoiceOption = { value: AuthChoice; label: string; hint?: string; + groupId?: AuthChoiceGroupId; + groupLabel?: string; + groupHint?: string; }; + export type AuthChoiceGroup = { value: AuthChoiceGroupId; label: string; @@ -16,310 +19,39 @@ export type AuthChoiceGroup = { options: AuthChoiceOption[]; }; -export const AUTH_CHOICE_GROUP_DEFS: { - value: AuthChoiceGroupId; - label: string; - hint?: string; - choices: AuthChoice[]; -}[] = [ - { - value: "openai", - label: "OpenAI", - hint: "Codex OAuth + API key", - choices: ["openai-codex", "openai-api-key"], - }, - { - value: "anthropic", - label: "Anthropic", - hint: "setup-token + API key", - choices: ["token", "apiKey"], - }, +export const CORE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ { value: "chutes", - label: "Chutes", - hint: "OAuth", - choices: ["chutes"], + label: "Chutes (OAuth)", + groupId: "chutes", + groupLabel: "Chutes", + groupHint: "OAuth", }, { - value: "minimax", - label: "MiniMax", - hint: "M2.5 (recommended)", - choices: ["minimax-global-oauth", "minimax-global-api", "minimax-cn-oauth", "minimax-cn-api"], + value: "litellm-api-key", + label: "LiteLLM API key", + hint: "Unified gateway for 100+ LLM providers", + groupId: "litellm", + groupLabel: "LiteLLM", + groupHint: "Unified LLM gateway (100+ providers)", }, { - value: "moonshot", - label: "Moonshot AI (Kimi K2.5)", - hint: "Kimi K2.5 + Kimi Coding", - choices: ["moonshot-api-key", "moonshot-api-key-cn", "kimi-code-api-key"], - }, - { - value: "google", - label: "Google", - hint: "Gemini API key + OAuth", - choices: ["gemini-api-key", "google-gemini-cli"], - }, - { - value: "xai", - label: "xAI (Grok)", - hint: "API key", - choices: ["xai-api-key"], - }, - { - value: "mistral", - label: "Mistral AI", - hint: "API key", - choices: ["mistral-api-key"], - }, - { - value: "volcengine", - label: "Volcano Engine", - hint: "API key", - choices: ["volcengine-api-key"], - }, - { - value: "byteplus", - label: "BytePlus", - hint: "API key", - choices: ["byteplus-api-key"], - }, - { - value: "openrouter", - label: "OpenRouter", - hint: "API key", - choices: ["openrouter-api-key"], - }, - { - value: "kilocode", - label: "Kilo Gateway", - hint: "API key (OpenRouter-compatible)", - choices: ["kilocode-api-key"], - }, - { - value: "qwen", - label: "Qwen", - hint: "OAuth", - choices: ["qwen-portal"], - }, - { - value: "zai", - label: "Z.AI", - hint: "GLM Coding Plan / Global / CN", - choices: ["zai-coding-global", "zai-coding-cn", "zai-global", "zai-cn"], - }, - { - value: "qianfan", - label: "Qianfan", - hint: "API key", - choices: ["qianfan-api-key"], - }, - { - value: "modelstudio", - label: "Alibaba Cloud Model Studio", - hint: "Coding Plan API key (CN / Global)", - choices: ["modelstudio-api-key-cn", "modelstudio-api-key"], - }, - { - value: "copilot", - label: "Copilot", - hint: "GitHub + local proxy", - choices: ["github-copilot", "copilot-proxy"], - }, - { - value: "ai-gateway", - label: "Vercel AI Gateway", - hint: "API key", - choices: ["ai-gateway-api-key"], - }, - { - value: "opencode", - label: "OpenCode", - hint: "Shared API key for Zen + Go catalogs", - choices: ["opencode-zen", "opencode-go"], - }, - { - value: "xiaomi", - label: "Xiaomi", - hint: "API key", - choices: ["xiaomi-api-key"], - }, - { - value: "synthetic", - label: "Synthetic", - hint: "Anthropic-compatible (multi-model)", - choices: ["synthetic-api-key"], - }, - { - value: "together", - label: "Together AI", - hint: "API key", - choices: ["together-api-key"], - }, - { - value: "huggingface", - label: "Hugging Face", - hint: "Inference API (HF token)", - choices: ["huggingface-api-key"], - }, - { - value: "venice", - label: "Venice AI", - hint: "Privacy-focused (uncensored models)", - choices: ["venice-api-key"], - }, - { - value: "litellm", - label: "LiteLLM", - hint: "Unified LLM gateway (100+ providers)", - choices: ["litellm-api-key"], - }, - { - value: "cloudflare-ai-gateway", - label: "Cloudflare AI Gateway", - hint: "Account ID + Gateway ID + API key", - choices: ["cloudflare-ai-gateway-api-key"], - }, - { - value: "custom", + value: "custom-api-key", label: "Custom Provider", hint: "Any OpenAI or Anthropic compatible endpoint", - choices: ["custom-api-key"], + groupId: "custom", + groupLabel: "Custom Provider", + groupHint: "Any OpenAI or Anthropic compatible endpoint", }, ]; -const PROVIDER_AUTH_CHOICE_OPTION_HINTS: Partial> = { - "litellm-api-key": "Unified gateway for 100+ LLM providers", - "cloudflare-ai-gateway-api-key": "Account ID + Gateway ID + API key", - "venice-api-key": "Privacy-focused inference (uncensored models)", - "together-api-key": "Access to Llama, DeepSeek, Qwen, and more open models", - "huggingface-api-key": "Inference Providers — OpenAI-compatible chat", - "opencode-zen": "Shared OpenCode key; curated Zen catalog", - "opencode-go": "Shared OpenCode key; Kimi/GLM/MiniMax Go catalog", -}; - -const PROVIDER_AUTH_CHOICE_OPTION_LABELS: Partial> = { - "moonshot-api-key": "Kimi API key (.ai)", - "moonshot-api-key-cn": "Kimi API key (.cn)", - "kimi-code-api-key": "Kimi Code API key (subscription)", - "cloudflare-ai-gateway-api-key": "Cloudflare AI Gateway", - "opencode-zen": "OpenCode Zen catalog", - "opencode-go": "OpenCode Go catalog", -}; - -function buildProviderAuthChoiceOptions(): AuthChoiceOption[] { - return ONBOARD_PROVIDER_AUTH_FLAGS.map((flag) => ({ - value: flag.authChoice, - label: PROVIDER_AUTH_CHOICE_OPTION_LABELS[flag.authChoice] ?? flag.description, - ...(PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice] - ? { hint: PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice] } - : {}), - })); -} - -export const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ - { - value: "token", - label: "Anthropic token (paste setup-token)", - hint: "run `claude setup-token` elsewhere, then paste the token here", - }, - { - value: "openai-codex", - label: "OpenAI Codex (ChatGPT OAuth)", - }, - { value: "chutes", label: "Chutes (OAuth)" }, - ...buildProviderAuthChoiceOptions(), - { - value: "moonshot-api-key-cn", - label: "Kimi API key (.cn)", - }, - { - value: "github-copilot", - label: "GitHub Copilot (GitHub device login)", - hint: "Uses GitHub device flow", - }, - { value: "gemini-api-key", label: "Google Gemini API key" }, - { - value: "google-gemini-cli", - label: "Google Gemini CLI OAuth", - hint: "Unofficial flow; review account-risk warning before use", - }, - { value: "zai-api-key", label: "Z.AI API key" }, - { - value: "zai-coding-global", - label: "Coding-Plan-Global", - hint: "GLM Coding Plan Global (api.z.ai)", - }, - { - value: "zai-coding-cn", - label: "Coding-Plan-CN", - hint: "GLM Coding Plan CN (open.bigmodel.cn)", - }, - { - value: "zai-global", - label: "Global", - hint: "Z.AI Global (api.z.ai)", - }, - { - value: "zai-cn", - label: "CN", - hint: "Z.AI CN (open.bigmodel.cn)", - }, - { - value: "xiaomi-api-key", - label: "Xiaomi API key", - }, - { - value: "minimax-global-oauth", - label: "MiniMax Global — OAuth (minimax.io)", - hint: "Only supports OAuth for the coding plan", - }, - { - value: "minimax-global-api", - label: "MiniMax Global — API Key (minimax.io)", - hint: "sk-api- or sk-cp- keys supported", - }, - { - value: "minimax-cn-oauth", - label: "MiniMax CN — OAuth (minimaxi.com)", - hint: "Only supports OAuth for the coding plan", - }, - { - value: "minimax-cn-api", - label: "MiniMax CN — API Key (minimaxi.com)", - hint: "sk-api- or sk-cp- keys supported", - }, - { value: "qwen-portal", label: "Qwen OAuth" }, - { - value: "copilot-proxy", - label: "Copilot Proxy (local)", - hint: "Local proxy for VS Code Copilot models", - }, - { value: "apiKey", label: "Anthropic API key" }, - { - value: "opencode-zen", - label: "OpenCode Zen catalog", - hint: "Claude, GPT, Gemini via opencode.ai/zen", - }, - { value: "qianfan-api-key", label: "Qianfan API key" }, - { - value: "modelstudio-api-key-cn", - label: "Coding Plan API Key for China (subscription)", - hint: "Endpoint: coding.dashscope.aliyuncs.com", - }, - { - value: "modelstudio-api-key", - label: "Coding Plan API Key for Global/Intl (subscription)", - hint: "Endpoint: coding-intl.dashscope.aliyuncs.com", - }, - { value: "custom-api-key", label: "Custom Provider" }, -]; - export function formatStaticAuthChoiceChoicesForCli(params?: { includeSkip?: boolean; includeLegacyAliases?: boolean; }): string { const includeSkip = params?.includeSkip ?? true; const includeLegacyAliases = params?.includeLegacyAliases ?? false; - const values = BASE_AUTH_CHOICE_OPTIONS.map((opt) => opt.value); + const values = CORE_AUTH_CHOICE_OPTIONS.map((opt) => opt.value); if (includeSkip) { values.push("skip"); diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index c45297a001e..933d998bc6e 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import type { ProviderAuthChoiceMetadata } from "../plugins/provider-auth-choices.js"; import type { ProviderWizardOption } from "../plugins/provider-wizard.js"; import { buildAuthChoiceGroups, @@ -8,9 +9,15 @@ import { } from "./auth-choice-options.js"; import { formatStaticAuthChoiceChoicesForCli } from "./auth-choice-options.static.js"; +const resolveManifestProviderAuthChoices = vi.hoisted(() => + vi.fn<() => ProviderAuthChoiceMetadata[]>(() => []), +); const resolveProviderWizardOptions = vi.hoisted(() => vi.fn<() => ProviderWizardOption[]>(() => []), ); +vi.mock("../plugins/provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoices, +})); vi.mock("../plugins/provider-wizard.js", () => ({ resolveProviderWizardOptions, })); @@ -25,7 +32,140 @@ function getOptions(includeSkip = false) { } describe("buildAuthChoiceOptions", () => { + beforeEach(() => { + resolveManifestProviderAuthChoices.mockReturnValue([]); + resolveProviderWizardOptions.mockReturnValue([]); + }); + it("includes core and provider-specific auth choices", () => { + resolveManifestProviderAuthChoices.mockReturnValue([ + { + pluginId: "github-copilot", + providerId: "github-copilot", + methodId: "device", + choiceId: "github-copilot", + choiceLabel: "GitHub Copilot", + groupId: "copilot", + groupLabel: "Copilot", + }, + { + pluginId: "anthropic", + providerId: "anthropic", + methodId: "setup-token", + choiceId: "token", + choiceLabel: "Anthropic token (paste setup-token)", + groupId: "anthropic", + groupLabel: "Anthropic", + }, + { + pluginId: "openai", + providerId: "openai", + methodId: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + }, + { + pluginId: "moonshot", + providerId: "moonshot", + methodId: "api-key", + choiceId: "moonshot-api-key", + choiceLabel: "Kimi API key (.ai)", + groupId: "moonshot", + groupLabel: "Moonshot AI (Kimi K2.5)", + }, + { + pluginId: "minimax", + providerId: "minimax", + methodId: "api-global", + choiceId: "minimax-global-api", + choiceLabel: "MiniMax API key (Global)", + groupId: "minimax", + groupLabel: "MiniMax", + }, + { + pluginId: "zai", + providerId: "zai", + methodId: "api-key", + choiceId: "zai-api-key", + choiceLabel: "Z.AI API key", + groupId: "zai", + groupLabel: "Z.AI", + }, + { + pluginId: "xiaomi", + providerId: "xiaomi", + methodId: "api-key", + choiceId: "xiaomi-api-key", + choiceLabel: "Xiaomi API key", + groupId: "xiaomi", + groupLabel: "Xiaomi", + }, + { + pluginId: "together", + providerId: "together", + methodId: "api-key", + choiceId: "together-api-key", + choiceLabel: "Together AI API key", + groupId: "together", + groupLabel: "Together AI", + }, + { + pluginId: "qwen-portal-auth", + providerId: "qwen-portal", + methodId: "device", + choiceId: "qwen-portal", + choiceLabel: "Qwen OAuth", + groupId: "qwen", + groupLabel: "Qwen", + }, + { + pluginId: "xai", + providerId: "xai", + methodId: "api-key", + choiceId: "xai-api-key", + choiceLabel: "xAI API key", + groupId: "xai", + groupLabel: "xAI (Grok)", + }, + { + pluginId: "mistral", + providerId: "mistral", + methodId: "api-key", + choiceId: "mistral-api-key", + choiceLabel: "Mistral API key", + groupId: "mistral", + groupLabel: "Mistral AI", + }, + { + pluginId: "volcengine", + providerId: "volcengine", + methodId: "api-key", + choiceId: "volcengine-api-key", + choiceLabel: "Volcano Engine API key", + groupId: "volcengine", + groupLabel: "Volcano Engine", + }, + { + pluginId: "byteplus", + providerId: "byteplus", + methodId: "api-key", + choiceId: "byteplus-api-key", + choiceLabel: "BytePlus API key", + groupId: "byteplus", + groupLabel: "BytePlus", + }, + { + pluginId: "opencode-go", + providerId: "opencode-go", + methodId: "api-key", + choiceId: "opencode-go", + choiceLabel: "OpenCode Go catalog", + groupId: "opencode", + groupLabel: "OpenCode", + }, + ]); resolveProviderWizardOptions.mockReturnValue([ { value: "ollama", @@ -57,15 +197,8 @@ describe("buildAuthChoiceOptions", () => { "zai-api-key", "xiaomi-api-key", "minimax-global-api", - "minimax-cn-api", - "minimax-global-oauth", "moonshot-api-key", - "moonshot-api-key-cn", - "kimi-code-api-key", "together-api-key", - "ai-gateway-api-key", - "cloudflare-ai-gateway-api-key", - "synthetic-api-key", "chutes", "qwen-portal", "xai-api-key", @@ -82,15 +215,37 @@ describe("buildAuthChoiceOptions", () => { }); it("builds cli help choices from the same catalog", () => { + resolveManifestProviderAuthChoices.mockReturnValue([ + { + pluginId: "openai", + providerId: "openai", + methodId: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + }, + ]); + resolveProviderWizardOptions.mockReturnValue([ + { + value: "ollama", + label: "Ollama", + hint: "Cloud and local open models", + groupId: "ollama", + groupLabel: "Ollama", + }, + ]); const options = getOptions(true); const cliChoices = formatAuthChoiceChoicesForCli({ includeLegacyAliases: false, includeSkip: true, }).split("|"); - for (const option of options) { - expect(cliChoices).toContain(option.value); - } + expect(cliChoices).toContain("openai-api-key"); + expect(cliChoices).toContain("chutes"); + expect(cliChoices).toContain("litellm-api-key"); + expect(cliChoices).toContain("custom-api-key"); + expect(cliChoices).toContain("skip"); + expect(options.some((option) => option.value === "ollama")).toBe(true); + expect(cliChoices).not.toContain("ollama"); }); it("can include legacy aliases in cli help choices", () => { @@ -106,6 +261,15 @@ describe("buildAuthChoiceOptions", () => { }); it("keeps static cli help choices off the plugin-backed catalog", () => { + resolveManifestProviderAuthChoices.mockReturnValue([ + { + pluginId: "openai", + providerId: "openai", + methodId: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + }, + ]); resolveProviderWizardOptions.mockReturnValue([ { value: "ollama", @@ -122,10 +286,12 @@ describe("buildAuthChoiceOptions", () => { }).split("|"); expect(cliChoices).not.toContain("ollama"); + expect(cliChoices).not.toContain("openai-api-key"); expect(cliChoices).toContain("skip"); }); it("shows Chutes in grouped provider selection", () => { + resolveManifestProviderAuthChoices.mockReturnValue([]); const { groups } = buildAuthChoiceGroups({ store: EMPTY_STORE, includeSkip: false, @@ -137,6 +303,26 @@ describe("buildAuthChoiceOptions", () => { }); it("groups OpenCode Zen and Go under one OpenCode entry", () => { + resolveManifestProviderAuthChoices.mockReturnValue([ + { + pluginId: "opencode", + providerId: "opencode", + methodId: "api-key", + choiceId: "opencode-zen", + choiceLabel: "OpenCode Zen catalog", + groupId: "opencode", + groupLabel: "OpenCode", + }, + { + pluginId: "opencode-go", + providerId: "opencode-go", + methodId: "api-key", + choiceId: "opencode-go", + choiceLabel: "OpenCode Go catalog", + groupId: "opencode", + groupLabel: "OpenCode", + }, + ]); const { groups } = buildAuthChoiceGroups({ store: EMPTY_STORE, includeSkip: false, @@ -149,6 +335,7 @@ describe("buildAuthChoiceOptions", () => { }); it("shows Ollama in grouped provider selection", () => { + resolveManifestProviderAuthChoices.mockReturnValue([]); resolveProviderWizardOptions.mockReturnValue([ { value: "ollama", diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index b4df26154aa..03bfa86749a 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -1,21 +1,51 @@ import type { AuthProfileStore } from "../agents/auth-profiles.js"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveManifestProviderAuthChoices } from "../plugins/provider-auth-choices.js"; import { resolveProviderWizardOptions } from "../plugins/provider-wizard.js"; import { - AUTH_CHOICE_GROUP_DEFS, - BASE_AUTH_CHOICE_OPTIONS, + CORE_AUTH_CHOICE_OPTIONS, type AuthChoiceGroup, type AuthChoiceOption, formatStaticAuthChoiceChoicesForCli, } from "./auth-choice-options.static.js"; import type { AuthChoice, AuthChoiceGroupId } from "./onboard-types.js"; -function resolveDynamicProviderCliChoices(params?: { +function compareOptionLabels(a: AuthChoiceOption, b: AuthChoiceOption): number { + return a.label.localeCompare(b.label); +} + +function compareGroupLabels(a: AuthChoiceGroup, b: AuthChoiceGroup): number { + return a.label.localeCompare(b.label); +} + +function resolveManifestProviderChoiceOptions(params?: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; -}): string[] { - return [...new Set(resolveProviderWizardOptions(params ?? {}).map((option) => option.value))]; +}): AuthChoiceOption[] { + return resolveManifestProviderAuthChoices(params ?? {}).map((choice) => ({ + value: choice.choiceId as AuthChoice, + label: choice.choiceLabel, + ...(choice.choiceHint ? { hint: choice.choiceHint } : {}), + ...(choice.groupId ? { groupId: choice.groupId as AuthChoiceGroupId } : {}), + ...(choice.groupLabel ? { groupLabel: choice.groupLabel } : {}), + ...(choice.groupHint ? { groupHint: choice.groupHint } : {}), + })); +} + +function resolveRuntimeFallbackProviderChoiceOptions(params?: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): AuthChoiceOption[] { + return resolveProviderWizardOptions(params ?? {}).map((option) => ({ + value: option.value as AuthChoice, + label: option.label, + ...(option.hint ? { hint: option.hint } : {}), + groupId: option.groupId as AuthChoiceGroupId, + groupLabel: option.groupLabel, + ...(option.groupHint ? { groupHint: option.groupHint } : {}), + })); } export function formatAuthChoiceChoicesForCli(params?: { @@ -27,10 +57,10 @@ export function formatAuthChoiceChoicesForCli(params?: { }): string { const values = [ ...formatStaticAuthChoiceChoicesForCli(params).split("|"), - ...resolveDynamicProviderCliChoices(params), + ...resolveManifestProviderChoiceOptions(params).map((option) => option.value), ]; - return values.join("|"); + return [...new Set(values)].join("|"); } export function buildAuthChoiceOptions(params: { @@ -41,23 +71,30 @@ export function buildAuthChoiceOptions(params: { env?: NodeJS.ProcessEnv; }): AuthChoiceOption[] { void params.store; - const optionByValue = new Map( - BASE_AUTH_CHOICE_OPTIONS.map((option) => [option.value, option]), - ); - - for (const option of resolveProviderWizardOptions({ + const optionByValue = new Map(); + for (const option of CORE_AUTH_CHOICE_OPTIONS) { + optionByValue.set(option.value, option); + } + for (const option of resolveManifestProviderChoiceOptions({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, })) { - optionByValue.set(option.value as AuthChoice, { - value: option.value as AuthChoice, - label: option.label, - hint: option.hint, - }); + optionByValue.set(option.value, option); + } + for (const option of resolveRuntimeFallbackProviderChoiceOptions({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + })) { + if (!optionByValue.has(option.value)) { + optionByValue.set(option.value, option); + } } - const options: AuthChoiceOption[] = Array.from(optionByValue.values()); + const options: AuthChoiceOption[] = Array.from(optionByValue.values()).toSorted( + compareOptionLabels, + ); if (params.includeSkip) { options.push({ value: "skip", label: "Skip for now" }); @@ -80,46 +117,30 @@ export function buildAuthChoiceGroups(params: { ...params, includeSkip: false, }); - const optionByValue = new Map( - options.map((opt) => [opt.value, opt]), - ); + const groupsById = new Map(); - const groups: AuthChoiceGroup[] = AUTH_CHOICE_GROUP_DEFS.map((group) => ({ - ...group, - options: group.choices - .map((choice) => optionByValue.get(choice)) - .filter((opt): opt is AuthChoiceOption => Boolean(opt)), - })); - const staticGroupIds = new Set(groups.map((group) => group.value)); - - for (const option of resolveProviderWizardOptions({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - })) { - const existing = groups.find((group) => group.value === option.groupId); - const nextOption = optionByValue.get(option.value as AuthChoice) ?? { - value: option.value as AuthChoice, - label: option.label, - hint: option.hint, - }; + for (const option of options) { + if (!option.groupId || !option.groupLabel) { + continue; + } + const existing = groupsById.get(option.groupId); if (existing) { - if (!existing.options.some((candidate) => candidate.value === nextOption.value)) { - existing.options.push(nextOption); - } + existing.options.push(option); continue; } - if (staticGroupIds.has(option.groupId as AuthChoiceGroupId)) { - continue; - } - groups.push({ - value: option.groupId as AuthChoiceGroupId, + groupsById.set(option.groupId, { + value: option.groupId, label: option.groupLabel, - hint: option.groupHint, - options: [nextOption], + ...(option.groupHint ? { hint: option.groupHint } : {}), + options: [option], }); - staticGroupIds.add(option.groupId as AuthChoiceGroupId); } + const groups = Array.from(groupsById.values()) + .map((group) => ({ + ...group, + options: [...group.options].toSorted(compareOptionLabels), + })) + .toSorted(compareGroupLabels); const skipOption = params.includeSkip ? ({ value: "skip", label: "Skip for now" } satisfies AuthChoiceOption) diff --git a/src/commands/auth-choice.apply.anthropic.test.ts b/src/commands/auth-choice.apply.anthropic.test.ts deleted file mode 100644 index 2833477ce96..00000000000 --- a/src/commands/auth-choice.apply.anthropic.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import anthropicPlugin from "../../extensions/anthropic/index.js"; -import type { ProviderPlugin } from "../plugins/types.js"; -import { registerSingleProviderPlugin } from "../test-utils/plugin-registration.js"; -import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js"; -import { applyAuthChoice } from "./auth-choice.js"; -import { ANTHROPIC_SETUP_TOKEN_PREFIX } from "./auth-token.js"; -import { - createAuthTestLifecycle, - createExitThrowingRuntime, - createWizardPrompter, - readAuthProfilesForAgent, - setupAuthTestEnv, -} from "./test-wizard-helpers.js"; - -const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); -vi.mock("../plugins/providers.js", () => ({ - resolvePluginProviders, -})); - -describe("applyAuthChoiceAnthropic", () => { - const lifecycle = createAuthTestLifecycle([ - "OPENCLAW_STATE_DIR", - "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", - "ANTHROPIC_API_KEY", - "ANTHROPIC_SETUP_TOKEN", - ]); - - async function setupTempState() { - const env = await setupAuthTestEnv("openclaw-anthropic-"); - lifecycle.setStateDir(env.stateDir); - return env.agentDir; - } - - afterEach(async () => { - resolvePluginProviders.mockReset(); - resolvePluginProviders.mockReturnValue([]); - await lifecycle.cleanup(); - }); - - it("writes env-backed Anthropic key as keyRef when secret-input-mode=ref", async () => { - const agentDir = await setupTempState(); - process.env.ANTHROPIC_API_KEY = "sk-ant-api-key"; - - const confirm = vi.fn(async () => true); - const prompter = createWizardPrompter({ confirm }, { defaultSelect: "ref" }); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoiceAnthropic({ - authChoice: "apiKey", - config: {}, - prompter, - runtime, - setDefaultModel: true, - }); - - expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.["anthropic:default"]).toMatchObject({ - provider: "anthropic", - mode: "api_key", - }); - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - expect(parsed.profiles?.["anthropic:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" }, - }); - expect(parsed.profiles?.["anthropic:default"]?.key).toBeUndefined(); - }); - - it("routes token onboarding through the anthropic provider plugin", async () => { - const agentDir = await setupTempState(); - process.env.ANTHROPIC_SETUP_TOKEN = `${ANTHROPIC_SETUP_TOKEN_PREFIX}${"x".repeat(100)}`; - resolvePluginProviders.mockReturnValue([registerSingleProviderPlugin(anthropicPlugin)]); - - const select = vi.fn().mockResolvedValueOnce("env"); - const text = vi.fn().mockResolvedValueOnce("ANTHROPIC_SETUP_TOKEN").mockResolvedValueOnce(""); - const prompter = createWizardPrompter({ select, text }, { defaultSelect: "ref" }); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoice({ - authChoice: "token", - config: {}, - prompter, - runtime, - setDefaultModel: true, - opts: { secretInputMode: "ref" }, - }); - - expect(result.config.auth?.profiles?.["anthropic:default"]).toMatchObject({ - provider: "anthropic", - mode: "token", - }); - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - expect(parsed.profiles?.["anthropic:default"]?.token).toBeUndefined(); - expect(parsed.profiles?.["anthropic:default"]?.tokenRef).toMatchObject({ - source: "env", - provider: "default", - id: "ANTHROPIC_SETUP_TOKEN", - }); - }); -}); diff --git a/src/commands/auth-choice.apply.anthropic.ts b/src/commands/auth-choice.apply.anthropic.ts deleted file mode 100644 index 64e98d3ebaf..00000000000 --- a/src/commands/auth-choice.apply.anthropic.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; -import { - normalizeSecretInputModeInput, - ensureApiKeyFromOptionEnvOrPrompt, -} from "./auth-choice.apply-helpers.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; -import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; -import { applyAuthProfileConfig, setAnthropicApiKey } from "./onboard-auth.js"; - -const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6"; - -export async function applyAuthChoiceAnthropic( - params: ApplyAuthChoiceParams, -): Promise { - const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - if ( - params.authChoice === "setup-token" || - params.authChoice === "oauth" || - params.authChoice === "token" - ) { - return await applyAuthChoicePluginProvider(params, { - authChoice: params.authChoice, - pluginId: "anthropic", - providerId: "anthropic", - methodId: "setup-token", - label: "Anthropic", - }); - } - - if (params.authChoice === "apiKey") { - if (params.opts?.tokenProvider && params.opts.tokenProvider !== "anthropic") { - return null; - } - - let nextConfig = params.config; - await ensureApiKeyFromOptionEnvOrPrompt({ - token: params.opts?.token, - tokenProvider: params.opts?.tokenProvider ?? "anthropic", - secretInputMode: requestedSecretInputMode, - config: nextConfig, - expectedProviders: ["anthropic"], - provider: "anthropic", - envLabel: "ANTHROPIC_API_KEY", - promptMessage: "Enter Anthropic API key", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: params.prompter, - setCredential: async (apiKey, mode) => - setAnthropicApiKey(apiKey, params.agentDir, { secretInputMode: mode }), - }); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "anthropic:default", - provider: "anthropic", - mode: "api_key", - }); - if (params.setDefaultModel) { - nextConfig = applyAgentDefaultModelPrimary(nextConfig, DEFAULT_ANTHROPIC_MODEL); - } - return { config: nextConfig }; - } - - return null; -} diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index f58a7312f74..eef881c2b13 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -63,6 +63,23 @@ const ZAI_AUTH_CHOICE_ENDPOINT: Partial< "zai-cn": "cn", }; +export function normalizeApiKeyTokenProviderAuthChoice(params: { + authChoice: AuthChoice; + tokenProvider?: string; +}): AuthChoice { + if (params.authChoice !== "apiKey" || !params.tokenProvider) { + return params.authChoice; + } + const normalizedTokenProvider = normalizeTokenProviderInput(params.tokenProvider); + if (!normalizedTokenProvider) { + return params.authChoice; + } + if (normalizedTokenProvider === "anthropic" || normalizedTokenProvider === "openai") { + return params.authChoice; + } + return API_KEY_TOKEN_PROVIDER_AUTH_CHOICE[normalizedTokenProvider] ?? params.authChoice; +} + export async function applyAuthChoiceApiProviders( params: ApplyAuthChoiceParams, ): Promise { @@ -77,14 +94,12 @@ export async function applyAuthChoiceApiProviders( (model) => (agentModelOverride = model), ); - let authChoice = params.authChoice; + const authChoice = normalizeApiKeyTokenProviderAuthChoice({ + authChoice: params.authChoice, + tokenProvider: params.opts?.tokenProvider, + }); const normalizedTokenProvider = normalizeTokenProviderInput(params.opts?.tokenProvider); const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - if (authChoice === "apiKey" && params.opts?.tokenProvider) { - if (normalizedTokenProvider !== "anthropic" && normalizedTokenProvider !== "openai") { - authChoice = API_KEY_TOKEN_PROVIDER_AUTH_CHOICE[normalizedTokenProvider ?? ""] ?? authChoice; - } - } if (authChoice === "openrouter-api-key") { return applyAuthChoiceOpenRouter(params); diff --git a/src/commands/auth-choice.apply.openai.test.ts b/src/commands/auth-choice.apply.openai.test.ts deleted file mode 100644 index 1d14f136f32..00000000000 --- a/src/commands/auth-choice.apply.openai.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js"; -import { - createAuthTestLifecycle, - createExitThrowingRuntime, - createWizardPrompter, - readAuthProfilesForAgent, - setupAuthTestEnv, -} from "./test-wizard-helpers.js"; - -describe("applyAuthChoiceOpenAI", () => { - const lifecycle = createAuthTestLifecycle([ - "OPENCLAW_STATE_DIR", - "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", - "OPENAI_API_KEY", - ]); - - async function setupTempState() { - const env = await setupAuthTestEnv("openclaw-openai-"); - lifecycle.setStateDir(env.stateDir); - return env.agentDir; - } - - afterEach(async () => { - await lifecycle.cleanup(); - }); - - it("writes env-backed OpenAI key as plaintext by default", async () => { - const agentDir = await setupTempState(); - process.env.OPENAI_API_KEY = "sk-openai-env"; // pragma: allowlist secret - - const confirm = vi.fn(async () => true); - const text = vi.fn(async () => "unused"); - const prompter = createWizardPrompter({ confirm, text }, { defaultSelect: "plaintext" }); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoiceOpenAI({ - authChoice: "openai-api-key", - config: {}, - prompter, - runtime, - setDefaultModel: true, - }); - - expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.["openai:default"]).toMatchObject({ - provider: "openai", - mode: "api_key", - }); - const defaultModel = result?.config.agents?.defaults?.model; - const primaryModel = typeof defaultModel === "string" ? defaultModel : defaultModel?.primary; - expect(primaryModel).toBe("openai/gpt-5.1-codex"); - expect(text).not.toHaveBeenCalled(); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - expect(parsed.profiles?.["openai:default"]?.key).toBe("sk-openai-env"); - expect(parsed.profiles?.["openai:default"]?.keyRef).toBeUndefined(); - }); - - it("writes env-backed OpenAI key as keyRef when secret-input-mode=ref", async () => { - const agentDir = await setupTempState(); - process.env.OPENAI_API_KEY = "sk-openai-env"; // pragma: allowlist secret - - const confirm = vi.fn(async () => true); - const text = vi.fn(async () => "unused"); - const prompter = createWizardPrompter({ confirm, text }, { defaultSelect: "ref" }); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoiceOpenAI({ - authChoice: "openai-api-key", - config: {}, - prompter, - runtime, - setDefaultModel: true, - }); - - expect(result).not.toBeNull(); - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - expect(parsed.profiles?.["openai:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - }); - expect(parsed.profiles?.["openai:default"]?.key).toBeUndefined(); - }); - - it("writes explicit token input into openai auth profile", async () => { - const agentDir = await setupTempState(); - - const prompter = createWizardPrompter({}, { defaultSelect: "" }); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoiceOpenAI({ - authChoice: "apiKey", - config: {}, - prompter, - runtime, - setDefaultModel: true, - opts: { - tokenProvider: "openai", - token: "sk-openai-token", - }, - }); - - expect(result).not.toBeNull(); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - expect(parsed.profiles?.["openai:default"]?.key).toBe("sk-openai-token"); - expect(parsed.profiles?.["openai:default"]?.keyRef).toBeUndefined(); - }); -}); diff --git a/src/commands/auth-choice.apply.openai.ts b/src/commands/auth-choice.apply.openai.ts deleted file mode 100644 index e03c9e2505a..00000000000 --- a/src/commands/auth-choice.apply.openai.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; -import { - createAuthChoiceAgentModelNoter, - ensureApiKeyFromOptionEnvOrPrompt, - normalizeSecretInputModeInput, -} from "./auth-choice.apply-helpers.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; -import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; -import { applyAuthProfileConfig, setOpenaiApiKey } from "./onboard-auth.js"; -import { - applyOpenAIConfig, - applyOpenAIProviderConfig, - OPENAI_DEFAULT_MODEL, -} from "./openai-model-default.js"; - -export async function applyAuthChoiceOpenAI( - params: ApplyAuthChoiceParams, -): Promise { - const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - const noteAgentModel = createAuthChoiceAgentModelNoter(params); - let authChoice = params.authChoice; - if (authChoice === "apiKey" && params.opts?.tokenProvider === "openai") { - authChoice = "openai-api-key"; - } - - if (authChoice === "openai-api-key") { - let nextConfig = params.config; - let agentModelOverride: string | undefined; - - const applyOpenAiDefaultModelChoice = async (): Promise => { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: OPENAI_DEFAULT_MODEL, - applyDefaultConfig: applyOpenAIConfig, - applyProviderConfig: applyOpenAIProviderConfig, - noteDefault: OPENAI_DEFAULT_MODEL, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - return { config: nextConfig, agentModelOverride }; - }; - - await ensureApiKeyFromOptionEnvOrPrompt({ - token: params.opts?.token, - tokenProvider: params.opts?.tokenProvider, - secretInputMode: requestedSecretInputMode, - config: nextConfig, - expectedProviders: ["openai"], - provider: "openai", - envLabel: "OPENAI_API_KEY", - promptMessage: "Enter OpenAI API key", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: params.prompter, - setCredential: async (apiKey, mode) => - setOpenaiApiKey(apiKey, params.agentDir, { secretInputMode: mode }), - }); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "openai:default", - provider: "openai", - mode: "api_key", - }); - return await applyOpenAiDefaultModelChoice(); - } - if (params.authChoice === "openai-codex") { - return await applyAuthChoicePluginProvider(params, { - authChoice: "openai-codex", - pluginId: "openai", - providerId: "openai-codex", - methodId: "oauth", - label: "OpenAI", - }); - } - - return null; -} diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index bafa9122e25..f8dab665f23 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -2,11 +2,10 @@ import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js"; -import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js"; import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js"; +import { normalizeApiKeyTokenProviderAuthChoice } from "./auth-choice.apply.api-providers.js"; import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js"; -import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js"; import { applyAuthChoiceLoadedPluginProvider } from "./auth-choice.apply.plugin-provider.js"; import type { AuthChoice, OnboardOptions } from "./onboard-types.js"; @@ -31,14 +30,16 @@ export async function applyAuthChoice( ): Promise { const normalizedAuthChoice = normalizeLegacyOnboardAuthChoice(params.authChoice) ?? params.authChoice; + const normalizedProviderAuthChoice = normalizeApiKeyTokenProviderAuthChoice({ + authChoice: normalizedAuthChoice, + tokenProvider: params.opts?.tokenProvider, + }); const normalizedParams = - normalizedAuthChoice === params.authChoice + normalizedProviderAuthChoice === params.authChoice ? params - : { ...params, authChoice: normalizedAuthChoice }; + : { ...params, authChoice: normalizedProviderAuthChoice }; const handlers: Array<(p: ApplyAuthChoiceParams) => Promise> = [ applyAuthChoiceLoadedPluginProvider, - applyAuthChoiceAnthropic, - applyAuthChoiceOpenAI, applyAuthChoiceOAuth, applyAuthChoiceApiProviders, applyAuthChoiceMiniMax, diff --git a/src/commands/auth-choice.preferred-provider.test.ts b/src/commands/auth-choice.preferred-provider.test.ts index 6f84763a308..6c9aae5f28e 100644 --- a/src/commands/auth-choice.preferred-provider.test.ts +++ b/src/commands/auth-choice.preferred-provider.test.ts @@ -1,8 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn()); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn()); const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); +vi.mock("../plugins/provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoice, +})); + vi.mock("../plugins/provider-wizard.js", () => ({ resolveProviderPluginChoice, })); @@ -16,10 +21,26 @@ import { resolvePreferredProviderForAuthChoice } from "./auth-choice.preferred-p describe("resolvePreferredProviderForAuthChoice", () => { beforeEach(() => { vi.clearAllMocks(); + resolveManifestProviderAuthChoice.mockReturnValue(undefined); resolvePluginProviders.mockReturnValue([]); resolveProviderPluginChoice.mockReturnValue(null); }); + it("prefers manifest metadata when available", async () => { + resolveManifestProviderAuthChoice.mockReturnValue({ + pluginId: "openai", + providerId: "openai", + methodId: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + }); + + await expect(resolvePreferredProviderForAuthChoice({ choice: "openai-api-key" })).resolves.toBe( + "openai", + ); + expect(resolvePluginProviders).not.toHaveBeenCalled(); + }); + it("normalizes legacy auth choices before plugin lookup", async () => { resolveProviderPluginChoice.mockReturnValue({ provider: { id: "anthropic", label: "Anthropic", auth: [] }, diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index a7faad5d3a4..7cab79d2215 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -1,51 +1,12 @@ import type { OpenClawConfig } from "../config/config.js"; +import { resolveManifestProviderAuthChoice } from "../plugins/provider-auth-choices.js"; import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js"; import type { AuthChoice } from "./onboard-types.js"; const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { chutes: "chutes", - token: "anthropic", - apiKey: "anthropic", - "openai-codex": "openai-codex", - "openai-api-key": "openai", - "openrouter-api-key": "openrouter", - "kilocode-api-key": "kilocode", - "ai-gateway-api-key": "vercel-ai-gateway", - "cloudflare-ai-gateway-api-key": "cloudflare-ai-gateway", - "moonshot-api-key": "moonshot", - "moonshot-api-key-cn": "moonshot", - "kimi-code-api-key": "kimi-coding", - "gemini-api-key": "google", - "google-gemini-cli": "google-gemini-cli", - "mistral-api-key": "mistral", - ollama: "ollama", - sglang: "sglang", - "zai-api-key": "zai", - "zai-coding-global": "zai", - "zai-coding-cn": "zai", - "zai-global": "zai", - "zai-cn": "zai", - "xiaomi-api-key": "xiaomi", - "synthetic-api-key": "synthetic", - "venice-api-key": "venice", - "together-api-key": "together", - "huggingface-api-key": "huggingface", - "github-copilot": "github-copilot", - "copilot-proxy": "copilot-proxy", - "minimax-global-oauth": "minimax-portal", - "minimax-global-api": "minimax", - "minimax-cn-oauth": "minimax-portal", - "minimax-cn-api": "minimax", - "opencode-zen": "opencode", - "opencode-go": "opencode-go", - "xai-api-key": "xai", "litellm-api-key": "litellm", - "qwen-portal": "qwen-portal", - "volcengine-api-key": "volcengine", - "byteplus-api-key": "byteplus", - "qianfan-api-key": "qianfan", "custom-api-key": "custom", - vllm: "vllm", }; export async function resolvePreferredProviderForAuthChoice(params: { @@ -55,6 +16,10 @@ export async function resolvePreferredProviderForAuthChoice(params: { env?: NodeJS.ProcessEnv; }): Promise { const choice = normalizeLegacyOnboardAuthChoice(params.choice) ?? params.choice; + const manifestResolved = resolveManifestProviderAuthChoice(choice, params); + if (manifestResolved) { + return manifestResolved.providerId; + } const [{ resolveProviderPluginChoice }, { resolvePluginProviders }] = await Promise.all([ import("../plugins/provider-wizard.js"), import("../plugins/providers.js"), diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 515291feb34..52a6211a87f 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -1,7 +1,15 @@ import fs from "node:fs/promises"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; +import anthropicPlugin from "../../extensions/anthropic/index.js"; +import huggingfacePlugin from "../../extensions/huggingface/index.js"; +import kimiCodingPlugin from "../../extensions/kimi-coding/index.js"; +import ollamaPlugin from "../../extensions/ollama/index.js"; +import openAIPlugin from "../../extensions/openai/index.js"; +import togetherPlugin from "../../extensions/together/index.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.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"; @@ -34,7 +42,7 @@ vi.mock("./openai-codex-oauth.js", () => ({ loginOpenAICodexOAuth, })); -const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); +const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); vi.mock("../plugins/providers.js", () => ({ resolvePluginProviders, })); @@ -55,6 +63,21 @@ type StoredAuthProfile = { metadata?: Record; }; +function createDefaultProviderPlugins() { + const captured = createCapturedPluginRegistration(); + for (const plugin of [ + anthropicPlugin, + huggingfacePlugin, + kimiCodingPlugin, + ollamaPlugin, + openAIPlugin, + togetherPlugin, + ]) { + plugin.register(captured.api); + } + return captured.providers; +} + describe("applyAuthChoice", () => { const lifecycle = createAuthTestLifecycle([ "OPENCLAW_STATE_DIR", @@ -127,6 +150,7 @@ describe("applyAuthChoice", () => { afterEach(async () => { vi.unstubAllGlobals(); resolvePluginProviders.mockReset(); + resolvePluginProviders.mockReturnValue(createDefaultProviderPlugins()); detectZaiEndpoint.mockReset(); detectZaiEndpoint.mockResolvedValue(null); loginOpenAICodexOAuth.mockReset(); @@ -135,6 +159,8 @@ describe("applyAuthChoice", () => { activeStateDir = null; }); + resolvePluginProviders.mockReturnValue(createDefaultProviderPlugins()); + it("does not throw when openai-codex oauth fails", async () => { await setupTempState(); diff --git a/src/commands/onboard-core-auth-flags.ts b/src/commands/onboard-core-auth-flags.ts new file mode 100644 index 00000000000..38663dbbccb --- /dev/null +++ b/src/commands/onboard-core-auth-flags.ts @@ -0,0 +1,21 @@ +import type { AuthChoice, OnboardOptions } from "./onboard-types.js"; + +type OnboardCoreAuthOptionKey = keyof Pick; + +export type OnboardCoreAuthFlag = { + optionKey: OnboardCoreAuthOptionKey; + authChoice: AuthChoice; + cliFlag: `--${string}`; + cliOption: `--${string} `; + description: string; +}; + +export const CORE_ONBOARD_AUTH_FLAGS: ReadonlyArray = [ + { + optionKey: "litellmApiKey", + authChoice: "litellm-api-key", + cliFlag: "--litellm-api-key", + cliOption: "--litellm-api-key ", + description: "LiteLLM API key", + }, +]; diff --git a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts index 212bb9dd890..4f29bcfa2b0 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts @@ -1,45 +1,13 @@ -import { ONBOARD_PROVIDER_AUTH_FLAGS } from "../../onboard-provider-auth-flags.js"; +import { resolveManifestProviderOnboardAuthFlags } from "../../../plugins/provider-auth-choices.js"; +import { CORE_ONBOARD_AUTH_FLAGS } from "../../onboard-core-auth-flags.js"; import type { AuthChoice, OnboardOptions } from "../../onboard-types.js"; type AuthChoiceFlag = { - optionKey: keyof AuthChoiceFlagOptions; + optionKey: string; authChoice: AuthChoice; label: string; }; -type AuthChoiceFlagOptions = Pick< - OnboardOptions, - | "anthropicApiKey" - | "geminiApiKey" - | "openaiApiKey" - | "mistralApiKey" - | "openrouterApiKey" - | "kilocodeApiKey" - | "aiGatewayApiKey" - | "cloudflareAiGatewayApiKey" - | "moonshotApiKey" - | "kimiCodeApiKey" - | "syntheticApiKey" - | "veniceApiKey" - | "togetherApiKey" - | "huggingfaceApiKey" - | "zaiApiKey" - | "xiaomiApiKey" - | "minimaxApiKey" - | "opencodeZenApiKey" - | "opencodeGoApiKey" - | "xaiApiKey" - | "litellmApiKey" - | "qianfanApiKey" - | "modelstudioApiKeyCn" - | "modelstudioApiKey" - | "volcengineApiKey" - | "byteplusApiKey" - | "customBaseUrl" - | "customModelId" - | "customApiKey" ->; - export type AuthChoiceInference = { choice?: AuthChoice; matches: AuthChoiceFlag[]; @@ -51,13 +19,21 @@ function hasStringValue(value: unknown): boolean { // Infer auth choice from explicit provider API key flags. export function inferAuthChoiceFromFlags(opts: OnboardOptions): AuthChoiceInference { - const matches: AuthChoiceFlag[] = ONBOARD_PROVIDER_AUTH_FLAGS.filter(({ optionKey }) => - hasStringValue(opts[optionKey]), - ).map((flag) => ({ - optionKey: flag.optionKey, - authChoice: flag.authChoice, - label: flag.cliFlag, - })); + const flags = [ + ...CORE_ONBOARD_AUTH_FLAGS, + ...resolveManifestProviderOnboardAuthFlags(), + ] as ReadonlyArray<{ + optionKey: string; + authChoice: string; + cliFlag: string; + }>; + const matches: AuthChoiceFlag[] = flags + .filter(({ optionKey }) => hasStringValue(opts[optionKey as keyof OnboardOptions])) + .map((flag) => ({ + optionKey: flag.optionKey, + authChoice: flag.authChoice as AuthChoice, + label: flag.cliFlag, + })); if ( hasStringValue(opts.customBaseUrl) || diff --git a/src/commands/onboard-provider-auth-flags.ts b/src/commands/onboard-provider-auth-flags.ts deleted file mode 100644 index 53df8cdc4c8..00000000000 --- a/src/commands/onboard-provider-auth-flags.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type { AuthChoice, OnboardOptions } from "./onboard-types.js"; - -type OnboardProviderAuthOptionKey = keyof Pick< - OnboardOptions, - | "anthropicApiKey" - | "openaiApiKey" - | "mistralApiKey" - | "openrouterApiKey" - | "kilocodeApiKey" - | "aiGatewayApiKey" - | "cloudflareAiGatewayApiKey" - | "moonshotApiKey" - | "kimiCodeApiKey" - | "geminiApiKey" - | "zaiApiKey" - | "xiaomiApiKey" - | "minimaxApiKey" - | "syntheticApiKey" - | "veniceApiKey" - | "togetherApiKey" - | "huggingfaceApiKey" - | "opencodeZenApiKey" - | "opencodeGoApiKey" - | "xaiApiKey" - | "litellmApiKey" - | "qianfanApiKey" - | "modelstudioApiKeyCn" - | "modelstudioApiKey" - | "volcengineApiKey" - | "byteplusApiKey" ->; - -export type OnboardProviderAuthFlag = { - optionKey: OnboardProviderAuthOptionKey; - authChoice: AuthChoice; - cliFlag: `--${string}`; - cliOption: `--${string} `; - description: string; -}; - -// Shared source for provider API-key flags used by CLI registration + non-interactive inference. -export const ONBOARD_PROVIDER_AUTH_FLAGS: ReadonlyArray = [ - { - optionKey: "anthropicApiKey", - authChoice: "apiKey", - cliFlag: "--anthropic-api-key", - cliOption: "--anthropic-api-key ", - description: "Anthropic API key", - }, - { - optionKey: "openaiApiKey", - authChoice: "openai-api-key", - cliFlag: "--openai-api-key", - cliOption: "--openai-api-key ", - description: "OpenAI API key", - }, - { - optionKey: "mistralApiKey", - authChoice: "mistral-api-key", - cliFlag: "--mistral-api-key", - cliOption: "--mistral-api-key ", - description: "Mistral API key", - }, - { - optionKey: "openrouterApiKey", - authChoice: "openrouter-api-key", - cliFlag: "--openrouter-api-key", - cliOption: "--openrouter-api-key ", - description: "OpenRouter API key", - }, - { - optionKey: "kilocodeApiKey", - authChoice: "kilocode-api-key", - cliFlag: "--kilocode-api-key", - cliOption: "--kilocode-api-key ", - description: "Kilo Gateway API key", - }, - { - optionKey: "aiGatewayApiKey", - authChoice: "ai-gateway-api-key", - cliFlag: "--ai-gateway-api-key", - cliOption: "--ai-gateway-api-key ", - description: "Vercel AI Gateway API key", - }, - { - optionKey: "cloudflareAiGatewayApiKey", - authChoice: "cloudflare-ai-gateway-api-key", - cliFlag: "--cloudflare-ai-gateway-api-key", - cliOption: "--cloudflare-ai-gateway-api-key ", - description: "Cloudflare AI Gateway API key", - }, - { - optionKey: "moonshotApiKey", - authChoice: "moonshot-api-key", - cliFlag: "--moonshot-api-key", - cliOption: "--moonshot-api-key ", - description: "Moonshot API key", - }, - { - optionKey: "kimiCodeApiKey", - authChoice: "kimi-code-api-key", - cliFlag: "--kimi-code-api-key", - cliOption: "--kimi-code-api-key ", - description: "Kimi Coding API key", - }, - { - optionKey: "geminiApiKey", - authChoice: "gemini-api-key", - cliFlag: "--gemini-api-key", - cliOption: "--gemini-api-key ", - description: "Gemini API key", - }, - { - optionKey: "zaiApiKey", - authChoice: "zai-api-key", - cliFlag: "--zai-api-key", - cliOption: "--zai-api-key ", - description: "Z.AI API key", - }, - { - optionKey: "xiaomiApiKey", - authChoice: "xiaomi-api-key", - cliFlag: "--xiaomi-api-key", - cliOption: "--xiaomi-api-key ", - description: "Xiaomi API key", - }, - { - optionKey: "minimaxApiKey", - authChoice: "minimax-global-api", - cliFlag: "--minimax-api-key", - cliOption: "--minimax-api-key ", - description: "MiniMax API key", - }, - { - optionKey: "syntheticApiKey", - authChoice: "synthetic-api-key", - cliFlag: "--synthetic-api-key", - cliOption: "--synthetic-api-key ", - description: "Synthetic API key", - }, - { - optionKey: "veniceApiKey", - authChoice: "venice-api-key", - cliFlag: "--venice-api-key", - cliOption: "--venice-api-key ", - description: "Venice API key", - }, - { - optionKey: "togetherApiKey", - authChoice: "together-api-key", - cliFlag: "--together-api-key", - cliOption: "--together-api-key ", - description: "Together AI API key", - }, - { - optionKey: "huggingfaceApiKey", - authChoice: "huggingface-api-key", - cliFlag: "--huggingface-api-key", - cliOption: "--huggingface-api-key ", - description: "Hugging Face API key (HF token)", - }, - { - optionKey: "opencodeZenApiKey", - authChoice: "opencode-zen", - cliFlag: "--opencode-zen-api-key", - cliOption: "--opencode-zen-api-key ", - description: "OpenCode API key (Zen catalog)", - }, - { - optionKey: "opencodeGoApiKey", - authChoice: "opencode-go", - cliFlag: "--opencode-go-api-key", - cliOption: "--opencode-go-api-key ", - description: "OpenCode API key (Go catalog)", - }, - { - optionKey: "xaiApiKey", - authChoice: "xai-api-key", - cliFlag: "--xai-api-key", - cliOption: "--xai-api-key ", - description: "xAI API key", - }, - { - optionKey: "litellmApiKey", - authChoice: "litellm-api-key", - cliFlag: "--litellm-api-key", - cliOption: "--litellm-api-key ", - description: "LiteLLM API key", - }, - { - optionKey: "qianfanApiKey", - authChoice: "qianfan-api-key", - cliFlag: "--qianfan-api-key", - cliOption: "--qianfan-api-key ", - description: "QIANFAN API key", - }, - { - optionKey: "modelstudioApiKeyCn", - authChoice: "modelstudio-api-key-cn", - cliFlag: "--modelstudio-api-key-cn", - cliOption: "--modelstudio-api-key-cn ", - description: "Alibaba Cloud Model Studio Coding Plan API key (China)", - }, - { - optionKey: "modelstudioApiKey", - authChoice: "modelstudio-api-key", - cliFlag: "--modelstudio-api-key", - cliOption: "--modelstudio-api-key ", - description: "Alibaba Cloud Model Studio Coding Plan API key (Global/Intl)", - }, - { - optionKey: "volcengineApiKey", - authChoice: "volcengine-api-key", - cliFlag: "--volcengine-api-key", - cliOption: "--volcengine-api-key ", - description: "Volcano Engine API key", - }, - { - optionKey: "byteplusApiKey", - authChoice: "byteplus-api-key", - cliFlag: "--byteplus-api-key", - cliOption: "--byteplus-api-key ", - description: "BytePlus API key", - }, -]; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 0ddedea92f8..1dd8ada1f6e 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -28,6 +28,7 @@ export type { ProviderAuthContext, ProviderAuthDoctorHintContext, ProviderAuthMethodNonInteractiveContext, + ProviderAuthMethod, ProviderAuthResult, } from "../plugins/types.js"; export type { diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 5156ea8a4a3..29edb0da176 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -207,6 +207,14 @@ describe("loadPluginManifestRegistry", () => { providerAuthEnvVars: { openai: ["OPENAI_API_KEY"], }, + providerAuthChoices: [ + { + provider: "openai", + method: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + }, + ], configSchema: { type: "object" }, }); @@ -219,6 +227,14 @@ describe("loadPluginManifestRegistry", () => { expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({ openai: ["OPENAI_API_KEY"], }); + expect(registry.plugins[0]?.providerAuthChoices).toEqual([ + { + provider: "openai", + method: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + }, + ]); }); it("reports bundled plugins as the duplicate winner for auto-discovered globals", () => { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 3a96d3036d5..f9f77f8f041 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -42,6 +42,7 @@ export type PluginManifestRecord = { channels: string[]; providers: string[]; providerAuthEnvVars?: Record; + providerAuthChoices?: PluginManifest["providerAuthChoices"]; skills: string[]; settingsFiles?: string[]; hooks: string[]; @@ -154,6 +155,7 @@ function buildRecord(params: { channels: params.manifest.channels ?? [], providers: params.manifest.providers ?? [], providerAuthEnvVars: params.manifest.providerAuthEnvVars, + providerAuthChoices: params.manifest.providerAuthChoices, skills: params.manifest.skills ?? [], settingsFiles: [], hooks: [], diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index c106996b7f4..d330b982ce1 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -14,7 +14,13 @@ export type PluginManifest = { kind?: PluginKind; channels?: string[]; providers?: string[]; + /** Cheap provider-auth env lookup without booting plugin runtime. */ providerAuthEnvVars?: Record; + /** + * Cheap onboarding/auth-choice metadata used by config validation, CLI help, + * and non-runtime auth-choice routing before provider runtime loads. + */ + providerAuthChoices?: PluginManifestProviderAuthChoice[]; skills?: string[]; name?: string; description?: string; @@ -22,6 +28,27 @@ export type PluginManifest = { uiHints?: Record; }; +export type PluginManifestProviderAuthChoice = { + /** Provider id owned by this manifest entry. */ + provider: string; + /** Provider auth method id that this choice should dispatch to. */ + method: string; + /** Stable auth-choice id used by onboarding and other CLI auth flows. */ + choiceId: string; + /** Optional user-facing choice label/hint for grouped onboarding UI. */ + choiceLabel?: string; + choiceHint?: string; + /** Optional grouping metadata for auth-choice pickers. */ + groupId?: string; + groupLabel?: string; + groupHint?: string; + /** Optional CLI flag metadata for one-flag auth flows such as API keys. */ + optionKey?: string; + cliFlag?: string; + cliOption?: string; + cliDescription?: string; +}; + export type PluginManifestLoadResult = | { ok: true; manifest: PluginManifest; manifestPath: string } | { ok: false; error: string; manifestPath: string }; @@ -52,6 +79,51 @@ function normalizeStringListRecord(value: unknown): Record | u return Object.keys(normalized).length > 0 ? normalized : undefined; } +function normalizeProviderAuthChoices( + value: unknown, +): PluginManifestProviderAuthChoice[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized: PluginManifestProviderAuthChoice[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const provider = typeof entry.provider === "string" ? entry.provider.trim() : ""; + const method = typeof entry.method === "string" ? entry.method.trim() : ""; + const choiceId = typeof entry.choiceId === "string" ? entry.choiceId.trim() : ""; + if (!provider || !method || !choiceId) { + continue; + } + const choiceLabel = typeof entry.choiceLabel === "string" ? entry.choiceLabel.trim() : ""; + const choiceHint = typeof entry.choiceHint === "string" ? entry.choiceHint.trim() : ""; + const groupId = typeof entry.groupId === "string" ? entry.groupId.trim() : ""; + const groupLabel = typeof entry.groupLabel === "string" ? entry.groupLabel.trim() : ""; + const groupHint = typeof entry.groupHint === "string" ? entry.groupHint.trim() : ""; + const optionKey = typeof entry.optionKey === "string" ? entry.optionKey.trim() : ""; + const cliFlag = typeof entry.cliFlag === "string" ? entry.cliFlag.trim() : ""; + const cliOption = typeof entry.cliOption === "string" ? entry.cliOption.trim() : ""; + const cliDescription = + typeof entry.cliDescription === "string" ? entry.cliDescription.trim() : ""; + normalized.push({ + provider, + method, + choiceId, + ...(choiceLabel ? { choiceLabel } : {}), + ...(choiceHint ? { choiceHint } : {}), + ...(groupId ? { groupId } : {}), + ...(groupLabel ? { groupLabel } : {}), + ...(groupHint ? { groupHint } : {}), + ...(optionKey ? { optionKey } : {}), + ...(cliFlag ? { cliFlag } : {}), + ...(cliOption ? { cliOption } : {}), + ...(cliDescription ? { cliDescription } : {}), + }); + } + return normalized.length > 0 ? normalized : undefined; +} + export function resolvePluginManifestPath(rootDir: string): string { for (const filename of PLUGIN_MANIFEST_FILENAMES) { const candidate = path.join(rootDir, filename); @@ -114,6 +186,7 @@ export function loadPluginManifest( const channels = normalizeStringList(raw.channels); const providers = normalizeStringList(raw.providers); const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars); + const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices); const skills = normalizeStringList(raw.skills); let uiHints: Record | undefined; @@ -130,6 +203,7 @@ export function loadPluginManifest( channels, providers, providerAuthEnvVars, + providerAuthChoices, skills, name, description, diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts index 0ef8b356ea0..df8e172fcfb 100644 --- a/src/plugins/provider-api-key-auth.ts +++ b/src/plugins/provider-api-key-auth.ts @@ -66,6 +66,7 @@ export function createProviderApiKeyAuthMethod( const opts = ctx.opts as Record | undefined; const flagValue = resolveStringOption(opts, params.optionKey); let capturedSecretInput: SecretInput | undefined; + let capturedCredential = false; let capturedMode: "plaintext" | "ref" | undefined; await ensureApiKeyFromOptionEnvOrPrompt({ @@ -89,13 +90,15 @@ export function createProviderApiKeyAuthMethod( noteTitle: params.noteTitle, setCredential: async (apiKey, mode) => { capturedSecretInput = apiKey; + capturedCredential = true; capturedMode = mode; }, }); - if (!capturedSecretInput) { + if (!capturedCredential) { throw new Error(`Missing API key input for provider "${params.providerId}".`); } + const credentialInput = capturedSecretInput ?? ""; return { profiles: [ @@ -103,7 +106,7 @@ export function createProviderApiKeyAuthMethod( profileId: resolveProfileId(params), credential: buildApiKeyCredential( params.providerId, - capturedSecretInput, + credentialInput, params.metadata, capturedMode ? { secretInputMode: capturedMode } : undefined, ), diff --git a/src/plugins/provider-auth-choices.test.ts b/src/plugins/provider-auth-choices.test.ts new file mode 100644 index 00000000000..0b9c9ccb5d2 --- /dev/null +++ b/src/plugins/provider-auth-choices.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; + +const loadPluginManifestRegistry = vi.hoisted(() => vi.fn()); + +vi.mock("./manifest-registry.js", () => ({ + loadPluginManifestRegistry, +})); + +import { + resolveManifestProviderAuthChoice, + resolveManifestProviderAuthChoices, + resolveManifestProviderOnboardAuthFlags, +} from "./provider-auth-choices.js"; + +describe("provider auth choice manifest helpers", () => { + it("flattens manifest auth choices", () => { + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "openai", + providerAuthChoices: [ + { + provider: "openai", + method: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + optionKey: "openaiApiKey", + cliFlag: "--openai-api-key", + cliOption: "--openai-api-key ", + }, + ], + }, + ], + }); + + expect(resolveManifestProviderAuthChoices()).toEqual([ + { + pluginId: "openai", + providerId: "openai", + methodId: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + optionKey: "openaiApiKey", + cliFlag: "--openai-api-key", + cliOption: "--openai-api-key ", + }, + ]); + expect(resolveManifestProviderAuthChoice("openai-api-key")?.providerId).toBe("openai"); + }); + + it("deduplicates flag metadata by option key + flag", () => { + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "moonshot", + providerAuthChoices: [ + { + provider: "moonshot", + method: "api-key", + choiceId: "moonshot-api-key", + choiceLabel: "Kimi API key (.ai)", + optionKey: "moonshotApiKey", + cliFlag: "--moonshot-api-key", + cliOption: "--moonshot-api-key ", + cliDescription: "Moonshot API key", + }, + { + provider: "moonshot", + method: "api-key-cn", + choiceId: "moonshot-api-key-cn", + choiceLabel: "Kimi API key (.cn)", + optionKey: "moonshotApiKey", + cliFlag: "--moonshot-api-key", + cliOption: "--moonshot-api-key ", + cliDescription: "Moonshot API key", + }, + ], + }, + ], + }); + + expect(resolveManifestProviderOnboardAuthFlags()).toEqual([ + { + optionKey: "moonshotApiKey", + authChoice: "moonshot-api-key", + cliFlag: "--moonshot-api-key", + cliOption: "--moonshot-api-key ", + description: "Moonshot API key", + }, + ]); + }); +}); diff --git a/src/plugins/provider-auth-choices.ts b/src/plugins/provider-auth-choices.ts new file mode 100644 index 00000000000..1d583c618f4 --- /dev/null +++ b/src/plugins/provider-auth-choices.ts @@ -0,0 +1,102 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; + +export type ProviderAuthChoiceMetadata = { + pluginId: string; + providerId: string; + methodId: string; + choiceId: string; + choiceLabel: string; + choiceHint?: string; + groupId?: string; + groupLabel?: string; + groupHint?: string; + optionKey?: string; + cliFlag?: string; + cliOption?: string; + cliDescription?: string; +}; + +export type ProviderOnboardAuthFlag = { + optionKey: string; + authChoice: string; + cliFlag: string; + cliOption: string; + description: string; +}; + +export function resolveManifestProviderAuthChoices(params?: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderAuthChoiceMetadata[] { + const registry = loadPluginManifestRegistry({ + config: params?.config, + workspaceDir: params?.workspaceDir, + env: params?.env, + }); + + return registry.plugins.flatMap((plugin) => + (plugin.providerAuthChoices ?? []).map((choice) => ({ + pluginId: plugin.id, + providerId: choice.provider, + methodId: choice.method, + choiceId: choice.choiceId, + choiceLabel: choice.choiceLabel ?? choice.choiceId, + ...(choice.choiceHint ? { choiceHint: choice.choiceHint } : {}), + ...(choice.groupId ? { groupId: choice.groupId } : {}), + ...(choice.groupLabel ? { groupLabel: choice.groupLabel } : {}), + ...(choice.groupHint ? { groupHint: choice.groupHint } : {}), + ...(choice.optionKey ? { optionKey: choice.optionKey } : {}), + ...(choice.cliFlag ? { cliFlag: choice.cliFlag } : {}), + ...(choice.cliOption ? { cliOption: choice.cliOption } : {}), + ...(choice.cliDescription ? { cliDescription: choice.cliDescription } : {}), + })), + ); +} + +export function resolveManifestProviderAuthChoice( + choiceId: string, + params?: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + }, +): ProviderAuthChoiceMetadata | undefined { + const normalized = choiceId.trim(); + if (!normalized) { + return undefined; + } + return resolveManifestProviderAuthChoices(params).find( + (choice) => choice.choiceId === normalized, + ); +} + +export function resolveManifestProviderOnboardAuthFlags(params?: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderOnboardAuthFlag[] { + const flags: ProviderOnboardAuthFlag[] = []; + const seen = new Set(); + + for (const choice of resolveManifestProviderAuthChoices(params)) { + if (!choice.optionKey || !choice.cliFlag || !choice.cliOption) { + continue; + } + const dedupeKey = `${choice.optionKey}::${choice.cliFlag}`; + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + flags.push({ + optionKey: choice.optionKey, + authChoice: choice.choiceId, + cliFlag: choice.cliFlag, + cliOption: choice.cliOption, + description: choice.cliDescription ?? choice.choiceLabel, + }); + } + + return flags; +} diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index af012bb6c5d..269b37d836a 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -9,6 +9,12 @@ const CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { litellm: ["LITELLM_API_KEY"], } as const; +const CORE_PROVIDER_SETUP_ENV_VAR_OVERRIDES = { + anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"], + chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], + "minimax-cn": ["MINIMAX_API_KEY"], +} as const; + /** * Provider auth env candidates used by generic auth resolution. * @@ -24,15 +30,15 @@ export const PROVIDER_AUTH_ENV_VAR_CANDIDATES: Record /** * Provider env vars used for setup/default secret refs and broad secret * scrubbing. This can include non-model providers and may intentionally choose - * a different preferred first env var than auth resolution. Keep the - * anthropic override in core so generic onboarding still prefers API keys over - * OAuth tokens when both are present. + * a different preferred first env var than auth resolution. + * + * Bundled provider auth envs come from plugin manifests. The override map here + * is only for true core/non-plugin providers and a few setup-specific ordering + * overrides where generic onboarding wants a different preferred env var. */ export const PROVIDER_ENV_VARS: Record = { ...PROVIDER_AUTH_ENV_VAR_CANDIDATES, - anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"], - chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], - "minimax-cn": ["MINIMAX_API_KEY"], + ...CORE_PROVIDER_SETUP_ENV_VAR_OVERRIDES, }; const EXTRA_PROVIDER_AUTH_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY"] as const; From 2054cb9431b6eed97e91b8b03e2ba3f2fd6b71e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 23:47:22 -0700 Subject: [PATCH 060/133] refactor: move remaining channel seams into plugins --- extensions/discord/src/channel.ts | 24 +- extensions/imessage/src/channel.ts | 13 +- extensions/signal/src/channel.ts | 14 +- extensions/slack/src/channel.ts | 24 +- extensions/telegram/src/channel.ts | 14 +- extensions/whatsapp/src/channel.ts | 13 +- src/agents/channel-tools.ts | 4 +- src/agents/pi-tools.policy.ts | 10 +- src/auto-reply/command-auth.ts | 63 +++-- src/auto-reply/commands-registry.data.ts | 26 +- src/auto-reply/reply/agent-runner-utils.ts | 10 +- src/auto-reply/reply/block-streaming.ts | 7 +- src/auto-reply/reply/commands-allowlist.ts | 222 ++------------- .../reply/get-reply-inline-actions.ts | 4 +- src/auto-reply/reply/groups.ts | 7 +- src/auto-reply/reply/mentions.ts | 5 +- src/auto-reply/reply/reply-elevated.ts | 19 +- src/auto-reply/reply/reply-threading.ts | 9 +- src/auto-reply/reply/route-reply.test.ts | 14 +- src/channels/plugins/outbound/discord.ts | 2 - .../plugins/outbound/imessage.test.ts | 2 +- src/channels/plugins/outbound/imessage.ts | 35 --- src/channels/plugins/outbound/signal.test.ts | 2 +- src/channels/plugins/outbound/signal.ts | 125 --------- .../outbound/slack.sendpayload.test.ts | 2 +- src/channels/plugins/outbound/slack.test.ts | 2 +- src/channels/plugins/outbound/slack.ts | 255 ------------------ src/channels/plugins/outbound/telegram.ts | 1 - src/channels/plugins/outbound/whatsapp.ts | 2 - src/channels/plugins/plugins-channel.test.ts | 3 +- src/channels/plugins/types.adapters.ts | 40 ++- src/config/sessions/metadata.ts | 5 +- ...gent.direct-delivery-core-channels.test.ts | 14 +- src/cron/isolated-agent.test-setup.ts | 3 +- src/infra/exec-approval-forwarder.test.ts | 12 +- ...tbeat-runner.returns-default-unset.test.ts | 2 +- src/infra/outbound/deliver.test-helpers.ts | 8 +- src/infra/outbound/deliver.test.ts | 8 +- src/infra/outbound/targets.test.ts | 3 +- src/plugin-sdk/index.ts | 1 + src/plugin-sdk/telegram.ts | 2 +- src/test-utils/imessage-test-plugin.ts | 2 +- 42 files changed, 246 insertions(+), 787 deletions(-) delete mode 100644 src/channels/plugins/outbound/discord.ts delete mode 100644 src/channels/plugins/outbound/imessage.ts delete mode 100644 src/channels/plugins/outbound/signal.ts delete mode 100644 src/channels/plugins/outbound/slack.ts delete mode 100644 src/channels/plugins/outbound/telegram.ts delete mode 100644 src/channels/plugins/outbound/whatsapp.ts diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index a16574bfb70..3c0da68a06a 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,6 +1,7 @@ import { Separator, TextDisplay } from "@buape/carbon"; import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { + buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, collectOpenGroupPolicyConfiguredRouteWarnings, @@ -262,16 +263,19 @@ export const discordPlugin: ChannelPlugin = { readDiscordAllowlistConfig(resolveDiscordAccount({ cfg, accountId })), resolveNames: async ({ cfg, accountId, entries }) => await resolveDiscordAllowlistNames({ cfg, accountId, entries }), - resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => - scope === "dm" - ? { - pathPrefix, - writeTarget, - readPaths: [["allowFrom"], ["dm", "allowFrom"]], - writePath: ["allowFrom"], - cleanupPaths: [["dm", "allowFrom"]], - } - : null, + applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + 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, + }), }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index aec66694ef8..295f16970ad 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,4 +1,5 @@ import { + buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/compat"; @@ -135,11 +136,13 @@ export const imessagePlugin: ChannelPlugin = { groupPolicy: account.config.groupPolicy, }; }, - resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({ - pathPrefix, - writeTarget, - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + channelId: "imessage", + normalize: ({ values }) => formatTrimmedAllowFromEntries(values), + resolvePaths: (scope) => ({ + readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], + writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + }), }), }, security: { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 80291872143..7567d68d4fa 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,4 +1,5 @@ import { + buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, createScopedAccountConfigAccessors, collectAllowlistProviderRestrictSendersWarnings, @@ -283,11 +284,14 @@ export const signalPlugin: ChannelPlugin = { groupPolicy: account.config.groupPolicy, }; }, - resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({ - pathPrefix, - writeTarget, - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + channelId: "signal", + normalize: ({ cfg, accountId, values }) => + signalConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + resolvePaths: (scope) => ({ + readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], + writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + }), }), }, security: { diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 2a8849b1671..33322732236 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,5 +1,6 @@ import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { + buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, collectOpenGroupPolicyConfiguredRouteWarnings, @@ -279,16 +280,19 @@ export const slackPlugin: ChannelPlugin = { readSlackAllowlistConfig(resolveSlackAccount({ cfg, accountId })), resolveNames: async ({ cfg, accountId, entries }) => await resolveSlackAllowlistNames({ cfg, accountId, entries }), - resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => - scope === "dm" - ? { - pathPrefix, - writeTarget, - readPaths: [["allowFrom"], ["dm", "allowFrom"]], - writePath: ["allowFrom"], - cleanupPaths: [["dm", "allowFrom"]], - } - : null, + applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + 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, + }), }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index be09a186baf..dda83e3f521 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,5 +1,6 @@ import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { + buildAccountScopedAllowlistConfigEditor, collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, createScopedAccountConfigAccessors, @@ -358,11 +359,14 @@ export const telegramPlugin: ChannelPlugin scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => readTelegramAllowlistConfig(resolveTelegramAccount({ cfg, accountId })), - resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({ - pathPrefix, - writeTarget, - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + channelId: "telegram", + normalize: ({ cfg, accountId, values }) => + telegramConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + resolvePaths: (scope) => ({ + readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], + writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + }), }), }, acpBindings: { diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index cf506e6912b..d7f437d3204 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,3 +1,4 @@ +import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat"; import { buildChannelConfigSchema, buildAccountScopedDmSecurityPolicy, @@ -195,11 +196,13 @@ export const whatsappPlugin: ChannelPlugin = { groupPolicy: account.groupPolicy, }; }, - resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({ - pathPrefix, - writeTarget, - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + channelId: "whatsapp", + normalize: ({ values }) => formatWhatsAppConfigAllowFromEntries(values), + resolvePaths: (scope) => ({ + readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], + writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + }), }), }, security: { diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index e49a090f509..242cce868c1 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -1,4 +1,3 @@ -import { getChannelDock } from "../channels/dock.js"; import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelAgentTool, @@ -73,8 +72,7 @@ export function resolveChannelMessageToolHints(params: { if (!channelId) { return []; } - const dock = getChannelDock(channelId); - const resolve = dock?.agentPrompt?.messageToolHints; + const resolve = getChannelPlugin(channelId)?.agentPrompt?.messageToolHints; if (!resolve) { return []; } diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index a6f8651f72d..4e7cea7c94e 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -1,4 +1,4 @@ -import { getChannelDock } from "../channels/dock.js"; +import { getChannelPlugin } from "../channels/plugins/index.js"; import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js"; @@ -315,14 +315,14 @@ export function resolveGroupToolPolicy(params: { if (!channel) { return undefined; } - let dock; + let plugin; try { - dock = getChannelDock(channel); + plugin = getChannelPlugin(channel); } catch { - dock = undefined; + plugin = undefined; } const toolsConfig = - dock?.groups?.resolveToolPolicy?.({ + plugin?.groups?.resolveToolPolicy?.({ cfg: params.config, groupId, groupChannel: params.groupChannel, diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts index ead6e6e0312..956c132d773 100644 --- a/src/auto-reply/command-auth.ts +++ b/src/auto-reply/command-auth.ts @@ -1,6 +1,5 @@ -import type { ChannelDock } from "../channels/dock.js"; -import { getChannelDock, listChannelDocks } from "../channels/dock.js"; -import type { ChannelId } from "../channels/plugins/types.js"; +import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; +import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js"; import { normalizeAnyChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; @@ -52,19 +51,19 @@ function resolveProviderFromContext(ctx: MsgContext, cfg: OpenClawConfig): Chann return normalized; } } - const configured = listChannelDocks() - .map((dock) => { - if (!dock.config?.resolveAllowFrom) { + const configured = listChannelPlugins() + .map((plugin) => { + if (!plugin.config?.resolveAllowFrom) { return null; } - const allowFrom = dock.config.resolveAllowFrom({ + const allowFrom = plugin.config.resolveAllowFrom({ cfg, accountId: ctx.AccountId, }); if (!Array.isArray(allowFrom) || allowFrom.length === 0) { return null; } - return dock.id; + return plugin.id; }) .filter((value): value is ChannelId => Boolean(value)); if (configured.length === 1) { @@ -74,29 +73,29 @@ function resolveProviderFromContext(ctx: MsgContext, cfg: OpenClawConfig): Chann } function formatAllowFromList(params: { - dock?: ChannelDock; + plugin?: ChannelPlugin; cfg: OpenClawConfig; accountId?: string | null; allowFrom: Array; }): string[] { - const { dock, cfg, accountId, allowFrom } = params; + const { plugin, cfg, accountId, allowFrom } = params; if (!allowFrom || allowFrom.length === 0) { return []; } - if (dock?.config?.formatAllowFrom) { - return dock.config.formatAllowFrom({ cfg, accountId, allowFrom }); + if (plugin?.config?.formatAllowFrom) { + return plugin.config.formatAllowFrom({ cfg, accountId, allowFrom }); } return normalizeStringEntries(allowFrom); } function normalizeAllowFromEntry(params: { - dock?: ChannelDock; + plugin?: ChannelPlugin; cfg: OpenClawConfig; accountId?: string | null; value: string; }): string[] { const normalized = formatAllowFromList({ - dock: params.dock, + plugin: params.plugin, cfg: params.cfg, accountId: params.accountId, allowFrom: [params.value], @@ -105,7 +104,7 @@ function normalizeAllowFromEntry(params: { } function resolveOwnerAllowFromList(params: { - dock?: ChannelDock; + plugin?: ChannelPlugin; cfg: OpenClawConfig; accountId?: string | null; providerId?: ChannelId; @@ -139,7 +138,7 @@ function resolveOwnerAllowFromList(params: { filtered.push(trimmed); } return formatAllowFromList({ - dock: params.dock, + plugin: params.plugin, cfg: params.cfg, accountId: params.accountId, allowFrom: filtered, @@ -152,12 +151,12 @@ function resolveOwnerAllowFromList(params: { * Returns null if commands.allowFrom is not configured at all (fall back to channel allowFrom). */ function resolveCommandsAllowFromList(params: { - dock?: ChannelDock; + plugin?: ChannelPlugin; cfg: OpenClawConfig; accountId?: string | null; providerId?: ChannelId; }): string[] | null { - const { dock, cfg, accountId, providerId } = params; + const { plugin, cfg, accountId, providerId } = params; const commandsAllowFrom = cfg.commands?.allowFrom; if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") { return null; // Not configured, fall back to channel allowFrom @@ -174,7 +173,7 @@ function resolveCommandsAllowFromList(params: { } return formatAllowFromList({ - dock, + plugin, cfg, accountId, allowFrom: rawList, @@ -211,7 +210,7 @@ function shouldUseFromAsSenderFallback(params: { } function resolveSenderCandidates(params: { - dock?: ChannelDock; + plugin?: ChannelPlugin; providerId?: ChannelId; cfg: OpenClawConfig; accountId?: string | null; @@ -220,7 +219,7 @@ function resolveSenderCandidates(params: { from?: string | null; chatType?: string | null; }): string[] { - const { dock, cfg, accountId } = params; + const { plugin, cfg, accountId } = params; const candidates: string[] = []; const pushCandidate = (value?: string | null) => { const trimmed = (value ?? "").trim(); @@ -245,7 +244,7 @@ function resolveSenderCandidates(params: { const normalized: string[] = []; for (const sender of candidates) { - const entries = normalizeAllowFromEntry({ dock, cfg, accountId, value: sender }); + const entries = normalizeAllowFromEntry({ plugin, cfg, accountId, value: sender }); for (const entry of entries) { if (!normalized.includes(entry)) { normalized.push(entry); @@ -262,36 +261,36 @@ export function resolveCommandAuthorization(params: { }): CommandAuthorization { const { ctx, cfg, commandAuthorized } = params; const providerId = resolveProviderFromContext(ctx, cfg); - const dock = providerId ? getChannelDock(providerId) : undefined; + const plugin = providerId ? getChannelPlugin(providerId) : undefined; const from = (ctx.From ?? "").trim(); const to = (ctx.To ?? "").trim(); // Check if commands.allowFrom is configured (separate command authorization) const commandsAllowFromList = resolveCommandsAllowFromList({ - dock, + plugin, cfg, accountId: ctx.AccountId, providerId, }); - const allowFromRaw = dock?.config?.resolveAllowFrom - ? dock.config.resolveAllowFrom({ cfg, accountId: ctx.AccountId }) + const allowFromRaw = plugin?.config?.resolveAllowFrom + ? plugin.config.resolveAllowFrom({ cfg, accountId: ctx.AccountId }) : []; const allowFromList = formatAllowFromList({ - dock, + plugin, cfg, accountId: ctx.AccountId, allowFrom: Array.isArray(allowFromRaw) ? allowFromRaw : [], }); const configOwnerAllowFromList = resolveOwnerAllowFromList({ - dock, + plugin, cfg, accountId: ctx.AccountId, providerId, allowFrom: cfg.commands?.ownerAllowFrom, }); const contextOwnerAllowFromList = resolveOwnerAllowFromList({ - dock, + plugin, cfg, accountId: ctx.AccountId, providerId, @@ -303,7 +302,7 @@ export function resolveCommandAuthorization(params: { const ownerCandidatesForCommands = allowAll ? [] : allowFromList.filter((entry) => entry !== "*"); if (!allowAll && ownerCandidatesForCommands.length === 0 && to) { const normalizedTo = normalizeAllowFromEntry({ - dock, + plugin, cfg, accountId: ctx.AccountId, value: to, @@ -328,7 +327,7 @@ export function resolveCommandAuthorization(params: { ); const senderCandidates = resolveSenderCandidates({ - dock, + plugin, providerId, cfg, accountId: ctx.AccountId, @@ -345,7 +344,7 @@ export function resolveCommandAuthorization(params: { : undefined; const senderId = matchedSender ?? senderCandidates[0]; - const enforceOwner = Boolean(dock?.commands?.enforceOwnerForCommands); + const enforceOwner = Boolean(plugin?.commands?.enforceOwnerForCommands); const senderIsOwnerByIdentity = Boolean(matchedSender); const senderIsOwnerByScope = isInternalMessageChannel(ctx.Provider) && diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 80f8d4bd73f..58064473543 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -1,4 +1,4 @@ -import { listChannelDocks } from "../channels/dock.js"; +import { listChannelPlugins } from "../channels/plugins/index.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import { COMMAND_ARG_FORMATTERS } from "./commands-args.js"; import type { @@ -46,14 +46,14 @@ function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefiniti }; } -type ChannelDock = ReturnType[number]; +type ChannelPlugin = ReturnType[number]; -function defineDockCommand(dock: ChannelDock): ChatCommandDefinition { +function defineDockCommand(plugin: ChannelPlugin): ChatCommandDefinition { return defineChatCommand({ - key: `dock:${dock.id}`, - nativeName: `dock_${dock.id}`, - description: `Switch to ${dock.id} for replies.`, - textAliases: [`/dock-${dock.id}`, `/dock_${dock.id}`], + key: `dock:${plugin.id}`, + nativeName: `dock_${plugin.id}`, + description: `Switch to ${plugin.id} for replies.`, + textAliases: [`/dock-${plugin.id}`, `/dock_${plugin.id}`], category: "docks", }); } @@ -758,9 +758,9 @@ function buildChatCommands(): ChatCommandDefinition[] { }, ], }), - ...listChannelDocks() - .filter((dock) => dock.capabilities.nativeCommands) - .map((dock) => defineDockCommand(dock)), + ...listChannelPlugins() + .filter((plugin) => plugin.capabilities.nativeCommands) + .map((plugin) => defineDockCommand(plugin)), ]; registerAlias(commands, "whoami", "/id"); @@ -792,9 +792,9 @@ export function getNativeCommandSurfaces(): Set { return cachedNativeCommandSurfaces; } cachedNativeCommandSurfaces = new Set( - listChannelDocks() - .filter((dock) => dock.capabilities.nativeCommands) - .map((dock) => dock.id), + listChannelPlugins() + .filter((plugin) => plugin.capabilities.nativeCommands) + .map((plugin) => plugin.id), ); cachedNativeRegistry = registry; return cachedNativeCommandSurfaces; diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index c6e71a9bab0..abf6322a287 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -1,6 +1,6 @@ import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js"; import type { NormalizedUsage } from "../../agents/usage.js"; -import { getChannelDock } from "../../channels/dock.js"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import { normalizeAnyChannelId, normalizeChannelId } from "../../channels/registry.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -44,8 +44,8 @@ export function buildThreadingToolContext(params: { } const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider); // Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init) - const dock = provider ? getChannelDock(provider) : undefined; - if (!dock?.threading?.buildToolContext) { + const threading = provider ? getChannelPlugin(provider)?.threading : undefined; + if (!threading?.buildToolContext) { return { currentChannelId: originTo?.trim() || undefined, currentChannelProvider: provider ?? (rawProvider as ChannelId), @@ -54,7 +54,7 @@ export function buildThreadingToolContext(params: { }; } const context = - dock.threading.buildToolContext({ + threading.buildToolContext({ cfg: config, accountId: sessionCtx.AccountId, context: { @@ -72,7 +72,7 @@ export function buildThreadingToolContext(params: { }) ?? {}; return { ...context, - currentChannelProvider: provider!, // guaranteed non-null since dock exists + currentChannelProvider: provider!, // guaranteed non-null since threading exists currentMessageId: context.currentMessageId ?? currentMessageId, }; } diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index b24ee8cac1a..9149f7c8562 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -1,5 +1,4 @@ -import { getChannelDock } from "../../channels/dock.js"; -import { normalizeChannelId } from "../../channels/plugins/index.js"; +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { BlockStreamingCoalesceConfig } from "../../config/types.js"; import { resolveAccountEntry } from "../../routing/account-lookup.js"; @@ -34,7 +33,7 @@ function resolveProviderChunkContext( const providerKey = normalizeChunkProvider(provider); const providerId = providerKey ? normalizeChannelId(providerKey) : null; const providerChunkLimit = providerId - ? getChannelDock(providerId)?.outbound?.textChunkLimit + ? getChannelPlugin(providerId)?.outbound?.textChunkLimit : undefined; const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId, { fallbackLimit: providerChunkLimit, @@ -209,7 +208,7 @@ export function resolveBlockStreamingCoalescing( // when chunkMode="newline", matching the delivery-time splitting behavior. const chunkMode = opts?.chunkMode ?? resolveChunkMode(cfg, providerKey, accountId); const providerDefaults = providerId - ? getChannelDock(providerId)?.streaming?.blockStreamingCoalesceDefaults + ? getChannelPlugin(providerId)?.streaming?.blockStreamingCoalesceDefaults : undefined; const providerCfg = resolveProviderBlockStreamingCoalesce({ cfg, diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index f371fcd0b62..7360fa20252 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -1,5 +1,4 @@ import { getChannelPlugin } from "../../channels/plugins/index.js"; -import { listPairingChannels } from "../../channels/plugins/pairing.js"; import type { ChannelId } from "../../channels/plugins/types.js"; import { normalizeChannelId } from "../../channels/registry.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -8,7 +7,6 @@ import { validateConfigObjectWithPlugins, writeConfigFile, } from "../../config/config.js"; -import { isBlockedObjectKey } from "../../infra/prototype-keys.js"; import { addChannelAllowFromStoreEntry, readChannelAllowFromStore, @@ -198,104 +196,6 @@ async function updatePairingStoreAllowlist(params: { } } -function resolveAccountTarget( - parsed: Record, - channelId: ChannelId, - accountId?: string | null, -) { - const channels = (parsed.channels ??= {}) as Record; - const channel = (channels[channelId] ??= {}) as Record; - const normalizedAccountId = normalizeAccountId(accountId); - if (isBlockedObjectKey(normalizedAccountId)) { - return { - target: channel, - pathPrefix: `channels.${channelId}`, - accountId: DEFAULT_ACCOUNT_ID, - writeTarget: { kind: "channel", scope: { channelId } } as const, - }; - } - const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object"); - const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts; - if (!useAccount) { - return { - target: channel, - pathPrefix: `channels.${channelId}`, - accountId: normalizedAccountId, - writeTarget: { kind: "channel", scope: { channelId } } as const, - }; - } - const accounts = (channel.accounts ??= {}) as Record; - const existingAccount = Object.hasOwn(accounts, normalizedAccountId) - ? accounts[normalizedAccountId] - : undefined; - if (!existingAccount || typeof existingAccount !== "object") { - accounts[normalizedAccountId] = {}; - } - const account = accounts[normalizedAccountId] as Record; - return { - target: account, - pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`, - accountId: normalizedAccountId, - writeTarget: { - kind: "account", - scope: { channelId, accountId: normalizedAccountId }, - } as const, - }; -} - -function getNestedValue(root: Record, path: string[]): unknown { - let current: unknown = root; - for (const key of path) { - if (!current || typeof current !== "object") { - return undefined; - } - current = (current as Record)[key]; - } - return current; -} - -function ensureNestedObject( - root: Record, - path: string[], -): Record { - let current = root; - for (const key of path) { - const existing = current[key]; - if (!existing || typeof existing !== "object") { - current[key] = {}; - } - current = current[key] as Record; - } - return current; -} - -function setNestedValue(root: Record, path: string[], value: unknown) { - if (path.length === 0) { - return; - } - if (path.length === 1) { - root[path[0]] = value; - return; - } - const parent = ensureNestedObject(root, path.slice(0, -1)); - parent[path[path.length - 1]] = value; -} - -function deleteNestedValue(root: Record, path: string[]) { - if (path.length === 0) { - return; - } - if (path.length === 1) { - delete root[path[0]]; - return; - } - const parent = getNestedValue(root, path.slice(0, -1)); - if (!parent || typeof parent !== "object") { - return; - } - delete (parent as Record)[path[path.length - 1]]; -} - function mapResolvedAllowlistNames(entries: ResolvedAllowlistName[]): Map { const map = new Map(); for (const entry of entries) { @@ -375,7 +275,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo const plugin = getChannelPlugin(channelId); if (parsed.action === "list") { - const supportsStore = listPairingChannels().includes(channelId); + const supportsStore = Boolean(plugin?.pairing); if (!plugin?.allowlist?.readConfig && !supportsStore) { return { shouldContinue: false, @@ -493,7 +393,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo } const shouldUpdateConfig = parsed.target !== "store"; - const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId); + const shouldTouchStore = parsed.target !== "config" && Boolean(plugin?.pairing); if (shouldUpdateConfig) { if (parsed.scope === "all") { @@ -502,19 +402,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo reply: { text: "⚠️ /allowlist add|remove requires scope dm or group." }, }; } - const { - target, - pathPrefix, - accountId: normalizedAccountId, - writeTarget, - } = resolveAccountTarget(structuredClone({ channels: {} }), channelId, accountId); - void target; - const editSpec = plugin?.allowlist?.resolveConfigEdit?.({ - scope: parsed.scope, - pathPrefix, - writeTarget, - }); - if (!editSpec) { + if (!plugin?.allowlist?.applyConfigEdit) { return { shouldContinue: false, reply: { @@ -531,14 +419,35 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }; } const parsedConfig = structuredClone(snapshot.parsed as Record); - const resolvedTarget = resolveAccountTarget(parsedConfig, channelId, accountId); + const editResult = await plugin.allowlist.applyConfigEdit({ + cfg: params.cfg, + parsedConfig, + accountId, + scope: parsed.scope, + action: parsed.action, + entry: parsed.entry, + }); + if (!editResult) { + return { + shouldContinue: false, + reply: { + text: `⚠️ ${channelId} does not support ${parsed.scope} allowlist edits via /allowlist.`, + }, + }; + } + if (editResult.kind === "invalid-entry") { + return { + shouldContinue: false, + reply: { text: "⚠️ Invalid allowlist entry." }, + }; + } const deniedText = resolveConfigWriteDeniedText({ cfg: params.cfg, channel: params.command.channel, channelId, accountId: params.ctx.AccountId, gatewayClientScopes: params.ctx.GatewayClientScopes, - target: editSpec.writeTarget, + target: editResult.writeTarget, }); if (deniedText) { return { @@ -548,82 +457,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }, }; } - - const existing: string[] = []; - for (const path of editSpec.readPaths) { - const existingRaw = getNestedValue(resolvedTarget.target, path); - if (!Array.isArray(existingRaw)) { - continue; - } - for (const entry of existingRaw) { - const value = String(entry).trim(); - if (!value || existing.includes(value)) { - continue; - } - existing.push(value); - } - } - - const normalizedEntry = normalizeAllowFrom({ - cfg: params.cfg, - channelId, - accountId: normalizedAccountId, - values: [parsed.entry], - }); - if (normalizedEntry.length === 0) { - return { - shouldContinue: false, - reply: { text: "⚠️ Invalid allowlist entry." }, - }; - } - - const existingNormalized = normalizeAllowFrom({ - cfg: params.cfg, - channelId, - accountId: normalizedAccountId, - values: existing, - }); - - const shouldMatch = (value: string) => normalizedEntry.includes(value); - - let configChanged = false; - let next = existing; - const configHasEntry = existingNormalized.some((value) => shouldMatch(value)); - if (parsed.action === "add") { - if (!configHasEntry) { - next = [...existing, parsed.entry.trim()]; - configChanged = true; - } - } - - if (parsed.action === "remove") { - const keep: string[] = []; - for (const entry of existing) { - const normalized = normalizeAllowFrom({ - cfg: params.cfg, - channelId, - accountId: normalizedAccountId, - values: [entry], - }); - if (normalized.some((value) => shouldMatch(value))) { - configChanged = true; - continue; - } - keep.push(entry); - } - next = keep; - } - - if (configChanged) { - if (next.length === 0) { - deleteNestedValue(resolvedTarget.target, editSpec.writePath); - } else { - setNestedValue(resolvedTarget.target, editSpec.writePath, next); - } - for (const path of editSpec.cleanupPaths ?? []) { - deleteNestedValue(resolvedTarget.target, path); - } - } + const configChanged = editResult.changed; if (configChanged) { const validated = validateConfigObjectWithPlugins(parsedConfig); @@ -655,7 +489,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo const scopeLabel = parsed.scope === "dm" ? "DM" : "group"; const locations: string[] = []; if (configChanged) { - locations.push(`${resolvedTarget.pathPrefix}.${editSpec.writePath.join(".")}`); + locations.push(editResult.pathLabel); } if (shouldTouchStore) { locations.push("pairing store"); diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index b4f921672f8..73983cfdc49 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -3,7 +3,7 @@ import { createOpenClawTools } from "../../agents/openclaw-tools.js"; import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js"; import type { SkillCommandSpec } from "../../agents/skills.js"; import { applyOwnerOnlyToolPolicy } from "../../agents/tool-policy.js"; -import { getChannelDock } from "../../channels/dock.js"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; @@ -402,7 +402,7 @@ export async function handleInlineActions(params: { const isEmptyConfig = Object.keys(cfg).length === 0; const skipWhenConfigEmpty = command.channelId - ? Boolean(getChannelDock(command.channelId)?.commands?.skipWhenConfigEmpty) + ? Boolean(getChannelPlugin(command.channelId)?.commands?.skipWhenConfigEmpty) : false; if ( skipWhenConfigEmpty && diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index dcf398d5a4b..acdbbe67faf 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -1,4 +1,3 @@ -import { getChannelDock } from "../../channels/dock.js"; import { getChannelPlugin, normalizeChannelId as normalizePluginChannelId, @@ -39,7 +38,7 @@ function resolveDockChannelId(raw?: string | null): ChannelId | null { return null; } try { - if (getChannelDock(normalized as ChannelId)) { + if (getChannelPlugin(normalized as ChannelId)) { return normalized as ChannelId; } } catch { @@ -68,7 +67,7 @@ export function resolveGroupRequireMention(params: { const groupSpace = ctx.GroupSpace?.trim(); let requireMention: boolean | undefined; try { - requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({ + requireMention = getChannelPlugin(channel)?.groups?.resolveRequireMention?.({ cfg, groupId, groupChannel, @@ -158,7 +157,7 @@ export function buildGroupIntro(params: { params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(); const groupSpace = params.sessionCtx.GroupSpace?.trim(); const providerIdsLine = providerId - ? getChannelDock(providerId)?.groups?.resolveGroupIntroHint?.({ + ? getChannelPlugin(providerId)?.groups?.resolveGroupIntroHint?.({ cfg: params.cfg, groupId, groupChannel, diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index 714e599e38a..5b60cf6688f 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -1,6 +1,5 @@ import { resolveAgentConfig } from "../../agents/agent-scope.js"; -import { getChannelDock } from "../../channels/dock.js"; -import { normalizeChannelId } from "../../channels/plugins/index.js"; +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { compileConfigRegexes, type ConfigRegexRejectReason } from "../../security/config-regex.js"; @@ -199,7 +198,7 @@ export function stripMentions( ): string { let result = text; const providerId = ctx.Provider ? normalizeChannelId(ctx.Provider) : null; - const providerMentions = providerId ? getChannelDock(providerId)?.mentions : undefined; + const providerMentions = providerId ? getChannelPlugin(providerId)?.mentions : undefined; const configRegexes = compileMentionPatternsCached({ patterns: normalizeMentionPatterns(resolveMentionPatterns(cfg, agentId)), flags: "gi", diff --git a/src/auto-reply/reply/reply-elevated.ts b/src/auto-reply/reply/reply-elevated.ts index 17da0058dd6..9a6e5093bda 100644 --- a/src/auto-reply/reply/reply-elevated.ts +++ b/src/auto-reply/reply/reply-elevated.ts @@ -1,6 +1,5 @@ import { resolveAgentConfig } from "../../agents/agent-scope.js"; -import { getChannelDock } from "../../channels/dock.js"; -import { normalizeChannelId } from "../../channels/plugins/index.js"; +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { AgentElevatedAllowFromConfig, OpenClawConfig } from "../../config/config.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; import type { MsgContext } from "../templating.js"; @@ -34,8 +33,9 @@ function resolveAllowFromFormatter(params: { accountId?: string; }): AllowFromFormatter { const normalizedProvider = normalizeChannelId(params.provider); - const dock = normalizedProvider ? getChannelDock(normalizedProvider) : undefined; - const formatAllowFrom = dock?.config?.formatAllowFrom; + const formatAllowFrom = normalizedProvider + ? getChannelPlugin(normalizedProvider)?.config?.formatAllowFrom + : undefined; if (!formatAllowFrom) { return (values) => normalizeStringEntries(values); } @@ -192,11 +192,12 @@ export function resolveElevatedPermissions(params: { } const normalizedProvider = normalizeChannelId(params.provider); - const dock = normalizedProvider ? getChannelDock(normalizedProvider) : undefined; - const fallbackAllowFrom = dock?.elevated?.allowFromFallback?.({ - cfg: params.cfg, - accountId: params.ctx.AccountId, - }); + const fallbackAllowFrom = normalizedProvider + ? getChannelPlugin(normalizedProvider)?.elevated?.allowFromFallback?.({ + cfg: params.cfg, + accountId: params.ctx.AccountId, + }) + : undefined; const formatAllowFrom = resolveAllowFromFormatter({ cfg: params.cfg, provider: params.provider, diff --git a/src/auto-reply/reply/reply-threading.ts b/src/auto-reply/reply/reply-threading.ts index 5db377bbd00..66871f226b7 100644 --- a/src/auto-reply/reply/reply-threading.ts +++ b/src/auto-reply/reply/reply-threading.ts @@ -1,5 +1,4 @@ -import { getChannelDock } from "../../channels/dock.js"; -import { normalizeChannelId } from "../../channels/plugins/index.js"; +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { ReplyToMode } from "../../config/types.js"; import type { OriginatingChannelType } from "../templating.js"; @@ -15,7 +14,7 @@ export function resolveReplyToMode( if (!provider) { return "all"; } - const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({ + const resolved = getChannelPlugin(provider)?.threading?.resolveReplyToMode?.({ cfg, accountId, chatType, @@ -59,9 +58,9 @@ export function createReplyToModeFilterForChannel( const isWebchat = normalized === "webchat"; // Default: allow explicit reply tags/directives even when replyToMode is "off". // Unknown channels fail closed; internal webchat stays allowed. - const dock = provider ? getChannelDock(provider) : undefined; + const threading = provider ? getChannelPlugin(provider)?.threading : undefined; const allowExplicitReplyTagsWhenOff = provider - ? (dock?.threading?.allowExplicitReplyTagsWhenOff ?? dock?.threading?.allowTagsWhenOff ?? true) + ? (threading?.allowExplicitReplyTagsWhenOff ?? threading?.allowTagsWhenOff ?? true) : isWebchat; return createReplyToModeFilter(mode, { allowExplicitReplyTagsWhenOff, diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index c0023ae1c37..b7b6cd31e9f 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -1,12 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js"; import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { discordOutbound } from "../../channels/plugins/outbound/discord.js"; -import { imessageOutbound } from "../../channels/plugins/outbound/imessage.js"; -import { signalOutbound } from "../../channels/plugins/outbound/signal.js"; -import { slackOutbound } from "../../channels/plugins/outbound/slack.js"; -import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js"; -import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js"; +import { + discordOutbound, + imessageOutbound, + signalOutbound, + slackOutbound, + telegramOutbound, + whatsappOutbound, +} from "../../../test/channel-outbounds.js"; import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { PluginRegistry } from "../../plugins/registry.js"; diff --git a/src/channels/plugins/outbound/discord.ts b/src/channels/plugins/outbound/discord.ts deleted file mode 100644 index 5b2126b8fcc..00000000000 --- a/src/channels/plugins/outbound/discord.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Shim: re-exports from extension -export * from "../../../../extensions/discord/src/outbound-adapter.js"; diff --git a/src/channels/plugins/outbound/imessage.test.ts b/src/channels/plugins/outbound/imessage.test.ts index b42b5a954c8..04c68a94f82 100644 --- a/src/channels/plugins/outbound/imessage.test.ts +++ b/src/channels/plugins/outbound/imessage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { imessageOutbound } from "../../../../test/channel-outbounds.js"; import type { OpenClawConfig } from "../../../config/config.js"; -import { imessageOutbound } from "./imessage.js"; describe("imessageOutbound", () => { const cfg: OpenClawConfig = { diff --git a/src/channels/plugins/outbound/imessage.ts b/src/channels/plugins/outbound/imessage.ts deleted file mode 100644 index b916c1e37df..00000000000 --- a/src/channels/plugins/outbound/imessage.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { sendMessageIMessage } from "../../../../extensions/imessage/src/send.js"; -import { - resolveOutboundSendDep, - type OutboundSendDeps, -} from "../../../infra/outbound/send-deps.js"; -import { - createScopedChannelMediaMaxBytesResolver, - createDirectTextMediaOutbound, -} from "./direct-text-media.js"; - -function resolveIMessageSender(deps: OutboundSendDeps | undefined) { - return ( - resolveOutboundSendDep(deps, "imessage") ?? sendMessageIMessage - ); -} - -export const imessageOutbound = createDirectTextMediaOutbound({ - channel: "imessage", - resolveSender: resolveIMessageSender, - resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("imessage"), - buildTextOptions: ({ cfg, maxBytes, accountId, replyToId }) => ({ - config: cfg, - maxBytes, - accountId: accountId ?? undefined, - replyToId: replyToId ?? undefined, - }), - buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, replyToId, mediaLocalRoots }) => ({ - config: cfg, - mediaUrl, - maxBytes, - accountId: accountId ?? undefined, - replyToId: replyToId ?? undefined, - mediaLocalRoots, - }), -}); diff --git a/src/channels/plugins/outbound/signal.test.ts b/src/channels/plugins/outbound/signal.test.ts index 9848c558965..5d28e4aefaf 100644 --- a/src/channels/plugins/outbound/signal.test.ts +++ b/src/channels/plugins/outbound/signal.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { signalOutbound } from "../../../../test/channel-outbounds.js"; import type { OpenClawConfig } from "../../../config/config.js"; -import { signalOutbound } from "./signal.js"; describe("signalOutbound", () => { const cfg: OpenClawConfig = { diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts deleted file mode 100644 index 9de4e6f0fa7..00000000000 --- a/src/channels/plugins/outbound/signal.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { markdownToSignalTextChunks } from "../../../../extensions/signal/src/format.js"; -import { sendMessageSignal } from "../../../../extensions/signal/src/send.js"; -import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js"; -import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js"; -import { - resolveOutboundSendDep, - type OutboundSendDeps, -} from "../../../infra/outbound/send-deps.js"; -import type { ChannelOutboundAdapter } from "../types.js"; -import { createScopedChannelMediaMaxBytesResolver } from "./direct-text-media.js"; - -function resolveSignalSender(deps: OutboundSendDeps | undefined) { - return resolveOutboundSendDep(deps, "signal") ?? sendMessageSignal; -} - -const resolveSignalMaxBytes = createScopedChannelMediaMaxBytesResolver("signal"); -type SignalSendOpts = NonNullable[2]>; - -function inferSignalTableMode(params: { cfg: SignalSendOpts["cfg"]; accountId?: string | null }) { - return resolveMarkdownTableMode({ - cfg: params.cfg, - channel: "signal", - accountId: params.accountId ?? undefined, - }); -} - -export const signalOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - chunker: (text, _limit) => text.split(/\n{2,}/).flatMap((chunk) => (chunk ? [chunk] : [])), - chunkerMode: "text", - textChunkLimit: 4000, - sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => { - const send = resolveSignalSender(deps); - const maxBytes = resolveSignalMaxBytes({ - cfg, - accountId: accountId ?? undefined, - }); - const limit = resolveTextChunkLimit(cfg, "signal", accountId ?? undefined, { - fallbackLimit: 4000, - }); - const tableMode = inferSignalTableMode({ cfg, accountId }); - let chunks = - limit === undefined - ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { tableMode }) - : markdownToSignalTextChunks(text, limit, { tableMode }); - if (chunks.length === 0 && text) { - chunks = [{ text, styles: [] }]; - } - const results = []; - for (const chunk of chunks) { - abortSignal?.throwIfAborted(); - const result = await send(to, chunk.text, { - cfg, - maxBytes, - accountId: accountId ?? undefined, - textMode: "plain", - textStyles: chunk.styles, - }); - results.push({ channel: "signal" as const, ...result }); - } - return results; - }, - sendFormattedMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - abortSignal, - }) => { - abortSignal?.throwIfAborted(); - const send = resolveSignalSender(deps); - const maxBytes = resolveSignalMaxBytes({ - cfg, - accountId: accountId ?? undefined, - }); - const tableMode = inferSignalTableMode({ cfg, accountId }); - const formatted = markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { - tableMode, - })[0] ?? { - text, - styles: [], - }; - const result = await send(to, formatted.text, { - cfg, - mediaUrl, - maxBytes, - accountId: accountId ?? undefined, - textMode: "plain", - textStyles: formatted.styles, - mediaLocalRoots, - }); - return { channel: "signal", ...result }; - }, - sendText: async ({ cfg, to, text, accountId, deps }) => { - const send = resolveSignalSender(deps); - const maxBytes = resolveSignalMaxBytes({ - cfg, - accountId: accountId ?? undefined, - }); - const result = await send(to, text, { - cfg, - maxBytes, - accountId: accountId ?? undefined, - }); - return { channel: "signal", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { - const send = resolveSignalSender(deps); - const maxBytes = resolveSignalMaxBytes({ - cfg, - accountId: accountId ?? undefined, - }); - const result = await send(to, text, { - cfg, - mediaUrl, - maxBytes, - accountId: accountId ?? undefined, - mediaLocalRoots, - }); - return { channel: "signal", ...result }; - }, -}; diff --git a/src/channels/plugins/outbound/slack.sendpayload.test.ts b/src/channels/plugins/outbound/slack.sendpayload.test.ts index 0bb551d0395..e1175023858 100644 --- a/src/channels/plugins/outbound/slack.sendpayload.test.ts +++ b/src/channels/plugins/outbound/slack.sendpayload.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "vitest"; +import { slackOutbound } from "../../../../test/channel-outbounds.js"; import type { ReplyPayload } from "../../../auto-reply/types.js"; import { installSendPayloadContractSuite, primeSendMock, } from "../../../test-utils/send-payload-contract.js"; -import { slackOutbound } from "./slack.js"; function createHarness(params: { payload: ReplyPayload; diff --git a/src/channels/plugins/outbound/slack.test.ts b/src/channels/plugins/outbound/slack.test.ts index 9b5c1843ce2..90c6f5e55ad 100644 --- a/src/channels/plugins/outbound/slack.test.ts +++ b/src/channels/plugins/outbound/slack.test.ts @@ -10,8 +10,8 @@ vi.mock("../../../plugins/hook-runner-global.js", () => ({ })); import { sendMessageSlack } from "../../../../extensions/slack/src/send.js"; +import { slackOutbound } from "../../../../test/channel-outbounds.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; -import { slackOutbound } from "./slack.js"; type SlackSendTextCtx = { to: string; diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts deleted file mode 100644 index 13729acb2ee..00000000000 --- a/src/channels/plugins/outbound/slack.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { parseSlackBlocksInput } from "../../../../extensions/slack/src/blocks-input.js"; -import { - buildSlackInteractiveBlocks, - type SlackBlock, -} from "../../../../extensions/slack/src/blocks-render.js"; -import { sendMessageSlack, type SlackSendIdentity } from "../../../../extensions/slack/src/send.js"; -import type { OutboundIdentity } from "../../../infra/outbound/identity.js"; -import { resolveOutboundSendDep } from "../../../infra/outbound/send-deps.js"; -import { - resolveInteractiveTextFallback, - type InteractiveReply, -} from "../../../interactive/payload.js"; -import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; -import type { ChannelOutboundAdapter } from "../types.js"; -import { - resolvePayloadMediaUrls, - sendPayloadMediaSequence, - sendTextMediaPayload, -} from "./direct-text-media.js"; - -const SLACK_MAX_BLOCKS = 50; - -function resolveRenderedInteractiveBlocks( - interactive?: InteractiveReply, -): SlackBlock[] | undefined { - if (!interactive) { - return undefined; - } - const blocks = buildSlackInteractiveBlocks(interactive); - return blocks.length > 0 ? blocks : undefined; -} - -function resolveSlackSendIdentity(identity?: OutboundIdentity): SlackSendIdentity | undefined { - if (!identity) { - return undefined; - } - const username = identity.name?.trim() || undefined; - const iconUrl = identity.avatarUrl?.trim() || undefined; - const rawEmoji = identity.emoji?.trim(); - const iconEmoji = !iconUrl && rawEmoji && /^:[^:\s]+:$/.test(rawEmoji) ? rawEmoji : undefined; - if (!username && !iconUrl && !iconEmoji) { - return undefined; - } - return { username, iconUrl, iconEmoji }; -} - -async function applySlackMessageSendingHooks(params: { - to: string; - text: string; - threadTs?: string; - accountId?: string; - mediaUrl?: string; -}): Promise<{ cancelled: boolean; text: string }> { - const hookRunner = getGlobalHookRunner(); - if (!hookRunner?.hasHooks("message_sending")) { - return { cancelled: false, text: params.text }; - } - const hookResult = await hookRunner.runMessageSending( - { - to: params.to, - content: params.text, - metadata: { - threadTs: params.threadTs, - channelId: params.to, - ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), - }, - }, - { channelId: "slack", accountId: params.accountId ?? undefined }, - ); - if (hookResult?.cancel) { - return { cancelled: true, text: params.text }; - } - return { cancelled: false, text: hookResult?.content ?? params.text }; -} - -async function sendSlackOutboundMessage(params: { - cfg: NonNullable[2]>["cfg"]; - to: string; - text: string; - mediaUrl?: string; - mediaLocalRoots?: readonly string[]; - blocks?: NonNullable[2]>["blocks"]; - accountId?: string | null; - deps?: { [channelId: string]: unknown } | null; - replyToId?: string | null; - threadId?: string | number | null; - identity?: OutboundIdentity; -}) { - const send = - resolveOutboundSendDep(params.deps, "slack") ?? sendMessageSlack; - // Use threadId fallback so routed tool notifications stay in the Slack thread. - const threadTs = - params.replyToId ?? (params.threadId != null ? String(params.threadId) : undefined); - const hookResult = await applySlackMessageSendingHooks({ - to: params.to, - text: params.text, - threadTs, - mediaUrl: params.mediaUrl, - accountId: params.accountId ?? undefined, - }); - if (hookResult.cancelled) { - return { - channel: "slack" as const, - messageId: "cancelled-by-hook", - channelId: params.to, - meta: { cancelled: true }, - }; - } - - const slackIdentity = resolveSlackSendIdentity(params.identity); - const result = await send(params.to, hookResult.text, { - cfg: params.cfg, - threadTs, - accountId: params.accountId ?? undefined, - ...(params.mediaUrl - ? { mediaUrl: params.mediaUrl, mediaLocalRoots: params.mediaLocalRoots } - : {}), - ...(params.blocks ? { blocks: params.blocks } : {}), - ...(slackIdentity ? { identity: slackIdentity } : {}), - }); - return { channel: "slack" as const, ...result }; -} - -function resolveSlackBlocks(payload: { - channelData?: Record; - interactive?: InteractiveReply; -}) { - const slackData = payload.channelData?.slack; - const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive); - if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) { - return renderedInteractive; - } - let existingBlocks: SlackBlock[] | undefined; - existingBlocks = parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks) as - | SlackBlock[] - | undefined; - const mergedBlocks = [...(existingBlocks ?? []), ...(renderedInteractive ?? [])]; - if (mergedBlocks.length === 0) { - return undefined; - } - if (mergedBlocks.length > SLACK_MAX_BLOCKS) { - throw new Error( - `Slack blocks cannot exceed ${SLACK_MAX_BLOCKS} items after interactive render`, - ); - } - return mergedBlocks; -} - -export const slackOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - chunker: null, - textChunkLimit: 4000, - sendPayload: async (ctx) => { - const payload = { - ...ctx.payload, - text: - resolveInteractiveTextFallback({ - text: ctx.payload.text, - interactive: ctx.payload.interactive, - }) ?? "", - }; - const blocks = resolveSlackBlocks(payload); - if (!blocks) { - return await sendTextMediaPayload({ - channel: "slack", - ctx: { - ...ctx, - payload, - }, - adapter: slackOutbound, - }); - } - const mediaUrls = resolvePayloadMediaUrls(payload); - if (mediaUrls.length === 0) { - return await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text: payload.text ?? "", - mediaLocalRoots: ctx.mediaLocalRoots, - blocks, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, - }); - } - await sendPayloadMediaSequence({ - text: "", - mediaUrls, - send: async ({ text, mediaUrl }) => - await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text, - mediaUrl, - mediaLocalRoots: ctx.mediaLocalRoots, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, - }), - }); - return await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text: payload.text ?? "", - mediaLocalRoots: ctx.mediaLocalRoots, - blocks, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, - }); - }, - sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => { - return await sendSlackOutboundMessage({ - cfg, - to, - text, - accountId, - deps, - replyToId, - threadId, - identity, - }); - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - identity, - }) => { - return await sendSlackOutboundMessage({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - identity, - }); - }, -}; diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts deleted file mode 100644 index 685ddb6ef31..00000000000 --- a/src/channels/plugins/outbound/telegram.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../../../extensions/telegram/src/outbound-adapter.js"; diff --git a/src/channels/plugins/outbound/whatsapp.ts b/src/channels/plugins/outbound/whatsapp.ts deleted file mode 100644 index 112ff4ccf91..00000000000 --- a/src/channels/plugins/outbound/whatsapp.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Shim: re-exports from extensions/whatsapp/src/outbound-adapter.ts -export * from "../../../../extensions/whatsapp/src/outbound-adapter.js"; diff --git a/src/channels/plugins/plugins-channel.test.ts b/src/channels/plugins/plugins-channel.test.ts index 01a9d29169a..bfd2c4ff556 100644 --- a/src/channels/plugins/plugins-channel.test.ts +++ b/src/channels/plugins/plugins-channel.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import { normalizeSignalAccountInput } from "../../../extensions/signal/src/setup-surface.js"; +import { telegramOutbound, whatsappOutbound } from "../../../test/channel-outbounds.js"; import type { OpenClawConfig } from "../../config/config.js"; import { normalizeIMessageMessagingTarget } from "./normalize/imessage.js"; import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize/signal.js"; -import { telegramOutbound } from "./outbound/telegram.js"; -import { whatsappOutbound } from "./outbound/whatsapp.js"; function expectWhatsAppTargetResolutionError(result: unknown) { expect(result).toEqual({ diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index c66fa0d463e..9f9e279bdc1 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -481,6 +481,35 @@ export type ChannelExecApprovalAdapter = { }; export type ChannelAllowlistAdapter = { + applyConfigEdit?: (params: { + cfg: OpenClawConfig; + parsedConfig: Record; + accountId?: string | null; + scope: "dm" | "group"; + action: "add" | "remove"; + entry: string; + }) => + | { + kind: "ok"; + changed: boolean; + pathLabel: string; + writeTarget: ConfigWriteTarget; + } + | { + kind: "invalid-entry"; + } + | Promise< + | { + kind: "ok"; + changed: boolean; + pathLabel: string; + writeTarget: ConfigWriteTarget; + } + | { + kind: "invalid-entry"; + } + > + | null; readConfig?: (params: { cfg: OpenClawConfig; accountId?: string | null }) => | { dmAllowFrom?: Array; @@ -504,17 +533,6 @@ export type ChannelAllowlistAdapter = { }) => | Array<{ input: string; resolved: boolean; name?: string | null }> | Promise>; - resolveConfigEdit?: (params: { - scope: "dm" | "group"; - pathPrefix: string; - writeTarget: ConfigWriteTarget; - }) => { - pathPrefix: string; - writeTarget: ConfigWriteTarget; - readPaths: string[][]; - writePath: string[]; - cleanupPaths?: string[][]; - } | null; supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean; }; diff --git a/src/config/sessions/metadata.ts b/src/config/sessions/metadata.ts index c438fd60f2b..b93cfcb5372 100644 --- a/src/config/sessions/metadata.ts +++ b/src/config/sessions/metadata.ts @@ -1,8 +1,7 @@ import type { MsgContext } from "../../auto-reply/templating.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import { resolveConversationLabel } from "../../channels/conversation-label.js"; -import { getChannelDock } from "../../channels/dock.js"; -import { normalizeChannelId } from "../../channels/plugins/index.js"; +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { buildGroupDisplayName, resolveGroupSessionKey } from "./group.js"; import type { GroupKeyResolution, SessionEntry, SessionOrigin } from "./types.js"; @@ -111,7 +110,7 @@ export function deriveGroupSessionPatch(params: { const normalizedChannel = normalizeChannelId(channel); const isChannelProvider = Boolean( normalizedChannel && - getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes("channel"), + getChannelPlugin(normalizedChannel)?.capabilities.chatTypes.includes("channel"), ); const nextGroupChannel = explicitChannel ?? diff --git a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts index 1950e361068..c477ded7f7d 100644 --- a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts +++ b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts @@ -1,12 +1,14 @@ import "./isolated-agent.mocks.js"; import { beforeEach, describe, expect, it } from "vitest"; +import { + discordOutbound, + imessageOutbound, + signalOutbound, + slackOutbound, + telegramOutbound, + whatsappOutbound, +} from "../../test/channel-outbounds.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; -import { discordOutbound } from "../channels/plugins/outbound/discord.js"; -import { imessageOutbound } from "../channels/plugins/outbound/imessage.js"; -import { signalOutbound } from "../channels/plugins/outbound/signal.js"; -import { slackOutbound } from "../channels/plugins/outbound/slack.js"; -import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; -import { whatsappOutbound } from "../channels/plugins/outbound/whatsapp.js"; import type { CliDeps } from "../cli/deps.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; diff --git a/src/cron/isolated-agent.test-setup.ts b/src/cron/isolated-agent.test-setup.ts index e6357531ad3..bdeb71fbaf4 100644 --- a/src/cron/isolated-agent.test-setup.ts +++ b/src/cron/isolated-agent.test-setup.ts @@ -1,9 +1,8 @@ import { vi } from "vitest"; +import { signalOutbound, telegramOutbound } from "../../test/channel-outbounds.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; -import { signalOutbound } from "../channels/plugins/outbound/signal.js"; -import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; import { callGateway } from "../gateway/call.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index d29856c3088..2dfc1c97dbd 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; +import { discordPlugin } from "../../extensions/discord/src/channel.js"; +import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { createExecApprovalForwarder } from "./exec-approval-forwarder.js"; const baseRequest = { @@ -25,7 +26,12 @@ const emptyRegistry = createTestRegistry([]); const defaultRegistry = createTestRegistry([ { pluginId: "telegram", - plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }), + plugin: telegramPlugin, + source: "test", + }, + { + pluginId: "discord", + plugin: discordPlugin, source: "test", }, ]); diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index a39914016f1..8bca1ca1de7 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -3,9 +3,9 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { parseTelegramTarget } from "../../extensions/telegram/src/targets.js"; +import { whatsappOutbound } from "../../test/channel-outbounds.js"; import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; import * as replyModule from "../auto-reply/reply.js"; -import { whatsappOutbound } from "../channels/plugins/outbound/whatsapp.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentIdFromSessionKey, diff --git a/src/infra/outbound/deliver.test-helpers.ts b/src/infra/outbound/deliver.test-helpers.ts index bc70c456dc5..77054dff7f3 100644 --- a/src/infra/outbound/deliver.test-helpers.ts +++ b/src/infra/outbound/deliver.test-helpers.ts @@ -1,7 +1,9 @@ import { vi } from "vitest"; -import { signalOutbound } from "../../channels/plugins/outbound/signal.js"; -import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js"; -import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js"; +import { + signalOutbound, + telegramOutbound, + whatsappOutbound, +} from "../../../test/channel-outbounds.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 075752df083..5323dd83e27 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -1,9 +1,11 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { markdownToSignalTextChunks } from "../../../extensions/signal/src/format.js"; -import { signalOutbound } from "../../channels/plugins/outbound/signal.js"; -import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js"; -import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js"; +import { + signalOutbound, + telegramOutbound, + whatsappOutbound, +} from "../../../test/channel-outbounds.js"; import type { OpenClawConfig } from "../../config/config.js"; import { STATE_DIR } from "../../config/paths.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 4d9645dc130..76bb9a2b3b5 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; -import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js"; -import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js"; +import { telegramOutbound, whatsappOutbound } from "../../../test/channel-outbounds.js"; import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions/types.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 6e70c8b7c19..5f1ccd91bbe 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -241,6 +241,7 @@ 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 { AllowFromEntrySchema, AllowFromListSchema, diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 7504994f70a..3e6d1df1257 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -54,7 +54,7 @@ export { parseTelegramThreadId, } from "../../extensions/telegram/src/outbound-params.js"; export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.js"; -export { sendTelegramPayloadMessages } from "../channels/plugins/outbound/telegram.js"; +export { sendTelegramPayloadMessages } from "../../extensions/telegram/src/outbound-adapter.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, diff --git a/src/test-utils/imessage-test-plugin.ts b/src/test-utils/imessage-test-plugin.ts index 962a1f7c33e..201ad3f9897 100644 --- a/src/test-utils/imessage-test-plugin.ts +++ b/src/test-utils/imessage-test-plugin.ts @@ -1,5 +1,5 @@ import { normalizeIMessageHandle } from "../../extensions/imessage/src/targets.js"; -import { imessageOutbound } from "../channels/plugins/outbound/imessage.js"; +import { imessageOutbound } from "../../test/channel-outbounds.js"; import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js"; import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js"; From 7cdd8a84a67b50cfdbcdb49489184a2c5dfe8337 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 23:47:43 -0700 Subject: [PATCH 061/133] refactor: add plugin-owned outbound adapters --- extensions/imessage/src/outbound-adapter.ts | 35 +++ extensions/signal/src/outbound-adapter.ts | 125 ++++++++++ extensions/slack/src/outbound-adapter.ts | 250 ++++++++++++++++++++ src/plugin-sdk/allowlist-config-edit.ts | 210 ++++++++++++++++ test/channel-outbounds.ts | 6 + 5 files changed, 626 insertions(+) create mode 100644 extensions/imessage/src/outbound-adapter.ts create mode 100644 extensions/signal/src/outbound-adapter.ts create mode 100644 extensions/slack/src/outbound-adapter.ts create mode 100644 src/plugin-sdk/allowlist-config-edit.ts create mode 100644 test/channel-outbounds.ts diff --git a/extensions/imessage/src/outbound-adapter.ts b/extensions/imessage/src/outbound-adapter.ts new file mode 100644 index 00000000000..ae5e7c2836a --- /dev/null +++ b/extensions/imessage/src/outbound-adapter.ts @@ -0,0 +1,35 @@ +import { + createScopedChannelMediaMaxBytesResolver, + createDirectTextMediaOutbound, +} from "../../../src/channels/plugins/outbound/direct-text-media.js"; +import { + resolveOutboundSendDep, + type OutboundSendDeps, +} from "../../../src/infra/outbound/send-deps.js"; +import { sendMessageIMessage } from "./send.js"; + +function resolveIMessageSender(deps: OutboundSendDeps | undefined) { + return ( + resolveOutboundSendDep(deps, "imessage") ?? sendMessageIMessage + ); +} + +export const imessageOutbound = createDirectTextMediaOutbound({ + channel: "imessage", + resolveSender: resolveIMessageSender, + resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("imessage"), + buildTextOptions: ({ cfg, maxBytes, accountId, replyToId }) => ({ + config: cfg, + maxBytes, + accountId: accountId ?? undefined, + replyToId: replyToId ?? undefined, + }), + buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, replyToId, mediaLocalRoots }) => ({ + config: cfg, + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + replyToId: replyToId ?? undefined, + mediaLocalRoots, + }), +}); diff --git a/extensions/signal/src/outbound-adapter.ts b/extensions/signal/src/outbound-adapter.ts new file mode 100644 index 00000000000..b0d77c12bd0 --- /dev/null +++ b/extensions/signal/src/outbound-adapter.ts @@ -0,0 +1,125 @@ +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 { markdownToSignalTextChunks } from "./format.js"; +import { sendMessageSignal } from "./send.js"; + +function resolveSignalSender(deps: OutboundSendDeps | undefined) { + return resolveOutboundSendDep(deps, "signal") ?? sendMessageSignal; +} + +const resolveSignalMaxBytes = createScopedChannelMediaMaxBytesResolver("signal"); +type SignalSendOpts = NonNullable[2]>; + +function inferSignalTableMode(params: { cfg: SignalSendOpts["cfg"]; accountId?: string | null }) { + return resolveMarkdownTableMode({ + cfg: params.cfg, + channel: "signal", + accountId: params.accountId ?? undefined, + }); +} + +export const signalOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: (text, _limit) => text.split(/\n{2,}/).flatMap((chunk) => (chunk ? [chunk] : [])), + chunkerMode: "text", + textChunkLimit: 4000, + sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => { + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + const limit = resolveTextChunkLimit(cfg, "signal", accountId ?? undefined, { + fallbackLimit: 4000, + }); + const tableMode = inferSignalTableMode({ cfg, accountId }); + let chunks = + limit === undefined + ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { tableMode }) + : markdownToSignalTextChunks(text, limit, { tableMode }); + if (chunks.length === 0 && text) { + chunks = [{ text, styles: [] }]; + } + const results = []; + for (const chunk of chunks) { + abortSignal?.throwIfAborted(); + const result = await send(to, chunk.text, { + cfg, + maxBytes, + accountId: accountId ?? undefined, + textMode: "plain", + textStyles: chunk.styles, + }); + results.push({ channel: "signal" as const, ...result }); + } + return results; + }, + sendFormattedMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + abortSignal, + }) => { + abortSignal?.throwIfAborted(); + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + const tableMode = inferSignalTableMode({ cfg, accountId }); + const formatted = markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { + tableMode, + })[0] ?? { + text, + styles: [], + }; + const result = await send(to, formatted.text, { + cfg, + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + textMode: "plain", + textStyles: formatted.styles, + mediaLocalRoots, + }); + return { channel: "signal", ...result }; + }, + sendText: async ({ cfg, to, text, accountId, deps }) => { + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + const result = await send(to, text, { + cfg, + maxBytes, + accountId: accountId ?? undefined, + }); + return { channel: "signal", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + const result = await send(to, text, { + cfg, + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + mediaLocalRoots, + }); + return { channel: "signal", ...result }; + }, +}; diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts new file mode 100644 index 00000000000..1c851c8f69e --- /dev/null +++ b/extensions/slack/src/outbound-adapter.ts @@ -0,0 +1,250 @@ +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"; +import { + resolveInteractiveTextFallback, + type InteractiveReply, +} from "../../../src/interactive/payload.js"; +import { getGlobalHookRunner } from "../../../src/plugins/hook-runner-global.js"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js"; +import { sendMessageSlack, type SlackSendIdentity } from "./send.js"; + +const SLACK_MAX_BLOCKS = 50; + +function resolveRenderedInteractiveBlocks( + interactive?: InteractiveReply, +): SlackBlock[] | undefined { + if (!interactive) { + return undefined; + } + const blocks = buildSlackInteractiveBlocks(interactive); + return blocks.length > 0 ? blocks : undefined; +} + +function resolveSlackSendIdentity(identity?: OutboundIdentity): SlackSendIdentity | undefined { + if (!identity) { + return undefined; + } + const username = identity.name?.trim() || undefined; + const iconUrl = identity.avatarUrl?.trim() || undefined; + const rawEmoji = identity.emoji?.trim(); + const iconEmoji = !iconUrl && rawEmoji && /^:[^:\s]+:$/.test(rawEmoji) ? rawEmoji : undefined; + if (!username && !iconUrl && !iconEmoji) { + return undefined; + } + return { username, iconUrl, iconEmoji }; +} + +async function applySlackMessageSendingHooks(params: { + to: string; + text: string; + threadTs?: string; + accountId?: string; + mediaUrl?: string; +}): Promise<{ cancelled: boolean; text: string }> { + const hookRunner = getGlobalHookRunner(); + if (!hookRunner?.hasHooks("message_sending")) { + return { cancelled: false, text: params.text }; + } + const hookResult = await hookRunner.runMessageSending( + { + to: params.to, + content: params.text, + metadata: { + threadTs: params.threadTs, + channelId: params.to, + ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), + }, + }, + { channelId: "slack", accountId: params.accountId ?? undefined }, + ); + if (hookResult?.cancel) { + return { cancelled: true, text: params.text }; + } + return { cancelled: false, text: hookResult?.content ?? params.text }; +} + +async function sendSlackOutboundMessage(params: { + cfg: NonNullable[2]>["cfg"]; + to: string; + text: string; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + blocks?: NonNullable[2]>["blocks"]; + accountId?: string | null; + deps?: { [channelId: string]: unknown } | null; + replyToId?: string | null; + threadId?: string | number | null; + identity?: OutboundIdentity; +}) { + const send = + resolveOutboundSendDep(params.deps, "slack") ?? sendMessageSlack; + const threadTs = + params.replyToId ?? (params.threadId != null ? String(params.threadId) : undefined); + const hookResult = await applySlackMessageSendingHooks({ + to: params.to, + text: params.text, + threadTs, + mediaUrl: params.mediaUrl, + accountId: params.accountId ?? undefined, + }); + if (hookResult.cancelled) { + return { + channel: "slack" as const, + messageId: "cancelled-by-hook", + channelId: params.to, + meta: { cancelled: true }, + }; + } + + const slackIdentity = resolveSlackSendIdentity(params.identity); + const result = await send(params.to, hookResult.text, { + cfg: params.cfg, + threadTs, + accountId: params.accountId ?? undefined, + ...(params.mediaUrl + ? { mediaUrl: params.mediaUrl, mediaLocalRoots: params.mediaLocalRoots } + : {}), + ...(params.blocks ? { blocks: params.blocks } : {}), + ...(slackIdentity ? { identity: slackIdentity } : {}), + }); + return { channel: "slack" as const, ...result }; +} + +function resolveSlackBlocks(payload: { + channelData?: Record; + interactive?: InteractiveReply; +}) { + const slackData = payload.channelData?.slack; + const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive); + if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) { + return renderedInteractive; + } + const existingBlocks = parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks) as + | SlackBlock[] + | undefined; + const mergedBlocks = [...(existingBlocks ?? []), ...(renderedInteractive ?? [])]; + if (mergedBlocks.length === 0) { + return undefined; + } + if (mergedBlocks.length > SLACK_MAX_BLOCKS) { + throw new Error( + `Slack blocks cannot exceed ${SLACK_MAX_BLOCKS} items after interactive render`, + ); + } + return mergedBlocks; +} + +export const slackOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: null, + textChunkLimit: 4000, + sendPayload: async (ctx) => { + const payload = { + ...ctx.payload, + text: + resolveInteractiveTextFallback({ + text: ctx.payload.text, + interactive: ctx.payload.interactive, + }) ?? "", + }; + const blocks = resolveSlackBlocks(payload); + if (!blocks) { + return await sendTextMediaPayload({ + channel: "slack", + ctx: { + ...ctx, + payload, + }, + adapter: slackOutbound, + }); + } + const mediaUrls = resolvePayloadMediaUrls(payload); + if (mediaUrls.length === 0) { + return await sendSlackOutboundMessage({ + cfg: ctx.cfg, + to: ctx.to, + text: payload.text ?? "", + mediaLocalRoots: ctx.mediaLocalRoots, + blocks, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + identity: ctx.identity, + }); + } + await sendPayloadMediaSequence({ + text: "", + mediaUrls, + send: async ({ text, mediaUrl }) => + await sendSlackOutboundMessage({ + cfg: ctx.cfg, + to: ctx.to, + text, + mediaUrl, + mediaLocalRoots: ctx.mediaLocalRoots, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + identity: ctx.identity, + }), + }); + return await sendSlackOutboundMessage({ + cfg: ctx.cfg, + to: ctx.to, + text: payload.text ?? "", + mediaLocalRoots: ctx.mediaLocalRoots, + blocks, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + identity: ctx.identity, + }); + }, + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => { + return await sendSlackOutboundMessage({ + cfg, + to, + text, + accountId, + deps, + replyToId, + threadId, + identity, + }); + }, + sendMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + identity, + }) => { + return await sendSlackOutboundMessage({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + identity, + }); + }, +}; diff --git a/src/plugin-sdk/allowlist-config-edit.ts b/src/plugin-sdk/allowlist-config-edit.ts new file mode 100644 index 00000000000..4c9f10ec278 --- /dev/null +++ b/src/plugin-sdk/allowlist-config-edit.ts @@ -0,0 +1,210 @@ +import type { ConfigWriteTarget } from "../channels/plugins/config-writes.js"; +import type { ChannelAllowlistAdapter } from "../channels/plugins/types.adapters.js"; +import type { ChannelId } from "../channels/plugins/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { isBlockedObjectKey } from "../infra/prototype-keys.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +type AllowlistConfigPaths = { + readPaths: string[][]; + writePath: string[]; + cleanupPaths?: string[][]; +}; + +function resolveAccountScopedWriteTarget( + parsed: Record, + channelId: ChannelId, + accountId?: string | null, +) { + const channels = (parsed.channels ??= {}) as Record; + const channel = (channels[channelId] ??= {}) as Record; + const normalizedAccountId = normalizeAccountId(accountId); + if (isBlockedObjectKey(normalizedAccountId)) { + return { + target: channel, + pathPrefix: `channels.${channelId}`, + writeTarget: { kind: "channel", scope: { channelId } } as const satisfies ConfigWriteTarget, + }; + } + const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object"); + const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts; + if (!useAccount) { + return { + target: channel, + pathPrefix: `channels.${channelId}`, + writeTarget: { kind: "channel", scope: { channelId } } as const satisfies ConfigWriteTarget, + }; + } + const accounts = (channel.accounts ??= {}) as Record; + const existingAccount = Object.hasOwn(accounts, normalizedAccountId) + ? accounts[normalizedAccountId] + : undefined; + if (!existingAccount || typeof existingAccount !== "object") { + accounts[normalizedAccountId] = {}; + } + const account = accounts[normalizedAccountId] as Record; + return { + target: account, + pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`, + writeTarget: { + kind: "account", + scope: { channelId, accountId: normalizedAccountId }, + } as const satisfies ConfigWriteTarget, + }; +} + +function getNestedValue(root: Record, path: string[]): unknown { + let current: unknown = root; + for (const key of path) { + if (!current || typeof current !== "object") { + return undefined; + } + current = (current as Record)[key]; + } + return current; +} + +function ensureNestedObject( + root: Record, + path: string[], +): Record { + let current = root; + for (const key of path) { + const existing = current[key]; + if (!existing || typeof existing !== "object") { + current[key] = {}; + } + current = current[key] as Record; + } + return current; +} + +function setNestedValue(root: Record, path: string[], value: unknown) { + if (path.length === 0) { + return; + } + if (path.length === 1) { + root[path[0]] = value; + return; + } + const parent = ensureNestedObject(root, path.slice(0, -1)); + parent[path[path.length - 1]] = value; +} + +function deleteNestedValue(root: Record, path: string[]) { + if (path.length === 0) { + return; + } + if (path.length === 1) { + delete root[path[0]]; + return; + } + const parent = getNestedValue(root, path.slice(0, -1)); + if (!parent || typeof parent !== "object") { + return; + } + delete (parent as Record)[path[path.length - 1]]; +} + +function applyAccountScopedAllowlistConfigEdit(params: { + parsedConfig: Record; + channelId: ChannelId; + accountId?: string | null; + action: "add" | "remove"; + entry: string; + normalize: (values: Array) => string[]; + paths: AllowlistConfigPaths; +}): NonNullable>>> { + const resolvedTarget = resolveAccountScopedWriteTarget( + params.parsedConfig, + params.channelId, + params.accountId, + ); + const existing: string[] = []; + for (const path of params.paths.readPaths) { + const existingRaw = getNestedValue(resolvedTarget.target, path); + if (!Array.isArray(existingRaw)) { + continue; + } + for (const entry of existingRaw) { + const value = String(entry).trim(); + if (!value || existing.includes(value)) { + continue; + } + existing.push(value); + } + } + + const normalizedEntry = params.normalize([params.entry]); + if (normalizedEntry.length === 0) { + return { kind: "invalid-entry" }; + } + + const existingNormalized = params.normalize(existing); + const shouldMatch = (value: string) => normalizedEntry.includes(value); + + let changed = false; + let next = existing; + const configHasEntry = existingNormalized.some((value) => shouldMatch(value)); + if (params.action === "add") { + if (!configHasEntry) { + next = [...existing, params.entry.trim()]; + changed = true; + } + } else { + const keep: string[] = []; + for (const entry of existing) { + const normalized = params.normalize([entry]); + if (normalized.some((value) => shouldMatch(value))) { + changed = true; + continue; + } + keep.push(entry); + } + next = keep; + } + + if (changed) { + if (next.length === 0) { + deleteNestedValue(resolvedTarget.target, params.paths.writePath); + } else { + setNestedValue(resolvedTarget.target, params.paths.writePath, next); + } + for (const path of params.paths.cleanupPaths ?? []) { + deleteNestedValue(resolvedTarget.target, path); + } + } + + return { + kind: "ok", + changed, + pathLabel: `${resolvedTarget.pathPrefix}.${params.paths.writePath.join(".")}`, + writeTarget: resolvedTarget.writeTarget, + }; +} + +export function buildAccountScopedAllowlistConfigEditor(params: { + channelId: ChannelId; + normalize: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + values: Array; + }) => string[]; + resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null; +}): NonNullable { + return ({ cfg, parsedConfig, accountId, scope, action, entry }) => { + const paths = params.resolvePaths(scope); + if (!paths) { + return null; + } + return applyAccountScopedAllowlistConfigEdit({ + parsedConfig, + channelId: params.channelId, + accountId, + action, + entry, + normalize: (values) => params.normalize({ cfg, accountId, values }), + paths, + }); + }; +} diff --git a/test/channel-outbounds.ts b/test/channel-outbounds.ts new file mode 100644 index 00000000000..a6da5a1c333 --- /dev/null +++ b/test/channel-outbounds.ts @@ -0,0 +1,6 @@ +export { discordOutbound } from "../extensions/discord/src/outbound-adapter.js"; +export { imessageOutbound } from "../extensions/imessage/src/outbound-adapter.js"; +export { signalOutbound } from "../extensions/signal/src/outbound-adapter.js"; +export { slackOutbound } from "../extensions/slack/src/outbound-adapter.js"; +export { telegramOutbound } from "../extensions/telegram/src/outbound-adapter.js"; +export { whatsappOutbound } from "../extensions/whatsapp/src/outbound-adapter.js"; From 5ece9afa8b57b51dea3a7aac45d4e788507bcad1 Mon Sep 17 00:00:00 2001 From: ObitaBot Date: Mon, 16 Mar 2026 13:43:26 +0800 Subject: [PATCH 062/133] fix: scope localStorage settings key by basePath to prevent cross-deployment conflicts - Add settingsKeyForGateway() function similar to tokenSessionKeyForGateway() - Use scoped key format: openclaw.control.settings.v1:https://example.com/gateway-a - Add migration from legacy static key on load - Fixes #47481 --- ui/src/ui/storage.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 0b23b3436a4..fcd9c4469c8 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,8 +1,12 @@ -const KEY = "openclaw.control.settings.v1"; +const SETTINGS_KEY_PREFIX = "openclaw.control.settings.v1:"; const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1"; const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:"; const MAX_SCOPED_SESSION_ENTRIES = 10; +function settingsKeyForGateway(gatewayUrl: string): string { + return `${SETTINGS_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`; +} + type ScopedSessionSelection = { sessionKey: string; lastActiveSessionKey: string; @@ -188,7 +192,9 @@ export function loadSettings(): UiSettings { }; try { - const raw = storage?.getItem(KEY); + // First check for legacy key (no scope), then check for scoped key + const scopedKey = settingsKeyForGateway(defaults.gatewayUrl); + const raw = storage?.getItem(scopedKey) ?? storage?.getItem(SETTINGS_KEY_PREFIX + "default") ?? storage?.getItem("openclaw.control.settings.v1"); if (!raw) { return defaults; } @@ -256,9 +262,11 @@ function persistSettings(next: UiSettings) { persistSessionToken(next.gatewayUrl, next.token); const storage = getSafeLocalStorage(); const scope = normalizeGatewayTokenScope(next.gatewayUrl); + const scopedKey = settingsKeyForGateway(next.gatewayUrl); let existingSessionsByGateway: Record = {}; try { - const raw = storage?.getItem(KEY); + // Try to migrate from legacy key or other scopes + const raw = storage?.getItem(scopedKey) ?? storage?.getItem(SETTINGS_KEY_PREFIX + "default") ?? storage?.getItem("openclaw.control.settings.v1"); if (raw) { const parsed = JSON.parse(raw) as PersistedUiSettings; if (parsed.sessionsByGateway && typeof parsed.sessionsByGateway === "object") { @@ -294,5 +302,5 @@ function persistSettings(next: UiSettings) { sessionsByGateway, ...(next.locale ? { locale: next.locale } : {}), }; - storage?.setItem(KEY, JSON.stringify(persisted)); + storage?.setItem(scopedKey, JSON.stringify(persisted)); } From d410debd01f72b0a46f6ac6fb4fc93e954009358 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:50:04 -0700 Subject: [PATCH 063/133] Tests: add provider contract suites --- src/plugins/contracts/suites.ts | 75 +++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/plugins/contracts/suites.ts diff --git a/src/plugins/contracts/suites.ts b/src/plugins/contracts/suites.ts new file mode 100644 index 00000000000..22898242855 --- /dev/null +++ b/src/plugins/contracts/suites.ts @@ -0,0 +1,75 @@ +import { expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { ProviderPlugin, WebSearchProviderPlugin } from "../types.js"; + +export function installProviderPluginContractSuite(params: { provider: ProviderPlugin }) { + it("satisfies the base provider plugin contract", () => { + const { provider } = params; + + expect(provider.id).toMatch(/^[a-z0-9][a-z0-9-]*$/); + expect(provider.label.trim()).not.toBe(""); + + if (provider.docsPath) { + expect(provider.docsPath.startsWith("/")).toBe(true); + } + if (provider.aliases) { + expect(provider.aliases).toEqual([...new Set(provider.aliases)]); + } + if (provider.envVars) { + expect(provider.envVars).toEqual([...new Set(provider.envVars)]); + expect(provider.envVars.every((entry) => entry.trim().length > 0)).toBe(true); + } + + expect(Array.isArray(provider.auth)).toBe(true); + const authIds = provider.auth.map((method) => method.id); + expect(authIds).toEqual([...new Set(authIds)]); + for (const method of provider.auth) { + expect(method.id.trim()).not.toBe(""); + expect(method.label.trim()).not.toBe(""); + expect(method.hint.trim()).not.toBe(""); + expect(typeof method.run).toBe("function"); + } + }); +} + +export function installWebSearchProviderContractSuite(params: { + provider: WebSearchProviderPlugin; + credentialValue: unknown; +}) { + it("satisfies the base web search provider contract", () => { + const { provider } = params; + + expect(provider.id).toMatch(/^[a-z0-9][a-z0-9-]*$/); + expect(provider.label.trim()).not.toBe(""); + expect(provider.hint.trim()).not.toBe(""); + expect(provider.placeholder.trim()).not.toBe(""); + expect(provider.signupUrl.startsWith("https://")).toBe(true); + if (provider.docsUrl) { + expect(provider.docsUrl.startsWith("http")).toBe(true); + } + + expect(provider.envVars).toEqual([...new Set(provider.envVars)]); + expect(provider.envVars.every((entry) => entry.trim().length > 0)).toBe(true); + + const searchConfigTarget: Record = {}; + provider.setCredentialValue(searchConfigTarget, params.credentialValue); + expect(provider.getCredentialValue(searchConfigTarget)).toEqual(params.credentialValue); + + const config = { + tools: { + web: { + search: { + provider: provider.id, + ...searchConfigTarget, + }, + }, + }, + } as OpenClawConfig; + const tool = provider.createTool({ config, searchConfig: searchConfigTarget }); + + expect(tool).not.toBeNull(); + expect(tool?.description.trim()).not.toBe(""); + expect(tool?.parameters).toEqual(expect.any(Object)); + expect(typeof tool?.execute).toBe("function"); + }); +} From a8878be0fdfa78edc86b596edaf44b49c4346852 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:50:12 -0700 Subject: [PATCH 064/133] Tests: add provider contract registry --- src/plugins/contracts/registry.ts | 126 ++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/plugins/contracts/registry.ts diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts new file mode 100644 index 00000000000..fce86382798 --- /dev/null +++ b/src/plugins/contracts/registry.ts @@ -0,0 +1,126 @@ +import anthropicPlugin from "../../../extensions/anthropic/index.js"; +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 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 minimaxPlugin from "../../../extensions/minimax/index.js"; +import mistralPlugin from "../../../extensions/mistral/index.js"; +import modelStudioPlugin from "../../../extensions/modelstudio/index.js"; +import moonshotPlugin from "../../../extensions/moonshot/index.js"; +import nvidiaPlugin from "../../../extensions/nvidia/index.js"; +import ollamaPlugin from "../../../extensions/ollama/index.js"; +import openAIPlugin from "../../../extensions/openai/index.js"; +import opencodeGoPlugin from "../../../extensions/opencode-go/index.js"; +import opencodePlugin from "../../../extensions/opencode/index.js"; +import openRouterPlugin from "../../../extensions/openrouter/index.js"; +import perplexityPlugin from "../../../extensions/perplexity/index.js"; +import qianfanPlugin from "../../../extensions/qianfan/index.js"; +import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js"; +import sglangPlugin from "../../../extensions/sglang/index.js"; +import syntheticPlugin from "../../../extensions/synthetic/index.js"; +import togetherPlugin from "../../../extensions/together/index.js"; +import venicePlugin from "../../../extensions/venice/index.js"; +import vercelAiGatewayPlugin from "../../../extensions/vercel-ai-gateway/index.js"; +import vllmPlugin from "../../../extensions/vllm/index.js"; +import volcenginePlugin from "../../../extensions/volcengine/index.js"; +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 { OpenClawPluginApi } from "../types.js"; +import type { ProviderPlugin, WebSearchProviderPlugin } from "../types.js"; + +type RegistrablePlugin = { + id: string; + register: (api: ReturnType["api"]) => void; +}; + +type ProviderContractEntry = { + pluginId: string; + provider: ProviderPlugin; +}; + +type WebSearchProviderContractEntry = { + pluginId: string; + provider: WebSearchProviderPlugin; + credentialValue: unknown; +}; + +const bundledProviderPlugins: RegistrablePlugin[] = [ + anthropicPlugin, + byteplusPlugin, + cloudflareAiGatewayPlugin, + copilotProxyPlugin, + githubCopilotPlugin, + googlePlugin, + huggingFacePlugin, + kilocodePlugin, + kimiCodingPlugin, + minimaxPlugin, + mistralPlugin, + modelStudioPlugin, + moonshotPlugin, + nvidiaPlugin, + ollamaPlugin, + opencodeGoPlugin, + opencodePlugin, + openAIPlugin, + openRouterPlugin, + qianfanPlugin, + qwenPortalPlugin, + sglangPlugin, + syntheticPlugin, + togetherPlugin, + venicePlugin, + vercelAiGatewayPlugin, + vllmPlugin, + volcenginePlugin, + xaiPlugin, + xiaomiPlugin, + zaiPlugin, +]; + +const bundledWebSearchPlugins: Array = [ + { ...bravePlugin, credentialValue: "BSA-test" }, + { ...firecrawlPlugin, credentialValue: "fc-test" }, + { ...googlePlugin, credentialValue: "AIza-test" }, + { ...moonshotPlugin, credentialValue: "sk-test" }, + { ...perplexityPlugin, credentialValue: "pplx-test" }, + { ...xaiPlugin, credentialValue: "xai-test" }, +]; + +function captureRegistrations(plugin: RegistrablePlugin) { + const captured = createCapturedPluginRegistration(); + const api = { + ...captured.api, + registerTool() {}, + } satisfies Partial; + plugin.register(api as OpenClawPluginApi); + return captured; +} + +export const providerContractRegistry: ProviderContractEntry[] = bundledProviderPlugins.flatMap( + (plugin) => { + const captured = captureRegistrations(plugin); + return captured.providers.map((provider) => ({ + pluginId: plugin.id, + provider, + })); + }, +); + +export const webSearchProviderContractRegistry: WebSearchProviderContractEntry[] = + bundledWebSearchPlugins.flatMap((plugin) => { + const captured = captureRegistrations(plugin); + return captured.webSearchProviders.map((provider) => ({ + pluginId: plugin.id, + provider, + credentialValue: plugin.credentialValue, + })); + }); From 0f502726e18a2826c77e1d7b1ac8a65d44d8ddf3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:50:20 -0700 Subject: [PATCH 065/133] Tests: add global provider contract suite --- src/plugins/contracts/provider.contract.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/plugins/contracts/provider.contract.test.ts diff --git a/src/plugins/contracts/provider.contract.test.ts b/src/plugins/contracts/provider.contract.test.ts new file mode 100644 index 00000000000..9ff8f7458d3 --- /dev/null +++ b/src/plugins/contracts/provider.contract.test.ts @@ -0,0 +1,11 @@ +import { describe } from "vitest"; +import { providerContractRegistry } from "./registry.js"; +import { installProviderPluginContractSuite } from "./suites.js"; + +for (const entry of providerContractRegistry) { + describe(`${entry.pluginId}:${entry.provider.id} provider contract`, () => { + installProviderPluginContractSuite({ + provider: entry.provider, + }); + }); +} From 9b73673313880d49ed926759fe6e5051fa9dd10d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:50:29 -0700 Subject: [PATCH 066/133] Tests: add global web search contract suite --- .../contracts/web-search-provider.contract.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/plugins/contracts/web-search-provider.contract.test.ts diff --git a/src/plugins/contracts/web-search-provider.contract.test.ts b/src/plugins/contracts/web-search-provider.contract.test.ts new file mode 100644 index 00000000000..c07eebaf6b5 --- /dev/null +++ b/src/plugins/contracts/web-search-provider.contract.test.ts @@ -0,0 +1,12 @@ +import { describe } from "vitest"; +import { webSearchProviderContractRegistry } from "./registry.js"; +import { installWebSearchProviderContractSuite } from "./suites.js"; + +for (const entry of webSearchProviderContractRegistry) { + describe(`${entry.pluginId}:${entry.provider.id} web search contract`, () => { + installWebSearchProviderContractSuite({ + provider: entry.provider, + credentialValue: entry.credentialValue, + }); + }); +} From a8367bb0ec1c1ed0f44219e3c7435a85ccccebef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 06:50:55 +0000 Subject: [PATCH 067/133] fix: stabilize ci gate --- ...ent.delivery-target-thread-session.test.ts | 11 +++++++ src/cron/isolated-agent.test-setup.ts | 16 +++++++++- src/infra/bonjour.ts | 30 +++++-------------- src/infra/outbound/channel-adapters.test.ts | 11 ++++++- src/infra/outbound/outbound-policy.test.ts | 11 ++++++- src/infra/outbound/outbound.test.ts | 9 ++++++ 6 files changed, 62 insertions(+), 26 deletions(-) diff --git a/src/cron/isolated-agent.delivery-target-thread-session.test.ts b/src/cron/isolated-agent.delivery-target-thread-session.test.ts index a034d7ab924..51f9c645a03 100644 --- a/src/cron/isolated-agent.delivery-target-thread-session.test.ts +++ b/src/cron/isolated-agent.delivery-target-thread-session.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { parseTelegramTarget } from "../../extensions/telegram/src/targets.js"; import type { OpenClawConfig } from "../config/config.js"; // Mock session store so we can control what entries exist. @@ -19,6 +20,16 @@ vi.mock("../channels/plugins/index.js", () => ({ getChannelPlugin: vi.fn(() => ({ meta: { label: "Telegram" }, config: {}, + messaging: { + parseExplicitTarget: ({ raw }: { raw: string }) => { + const target = parseTelegramTarget(raw); + return { + to: target.chatId, + threadId: target.messageThreadId, + chatType: target.chatType === "unknown" ? undefined : target.chatType, + }; + }, + }, outbound: { resolveTarget: ({ to }: { to?: string }) => to ? { ok: true, to } : { ok: false, error: new Error("missing") }, diff --git a/src/cron/isolated-agent.test-setup.ts b/src/cron/isolated-agent.test-setup.ts index bdeb71fbaf4..17093bda0a5 100644 --- a/src/cron/isolated-agent.test-setup.ts +++ b/src/cron/isolated-agent.test-setup.ts @@ -1,5 +1,6 @@ import { vi } from "vitest"; import { signalOutbound, telegramOutbound } from "../../test/channel-outbounds.js"; +import { parseTelegramTarget } from "../../extensions/telegram/src/targets.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; @@ -19,7 +20,20 @@ export function setupIsolatedAgentTurnMocks(params?: { fast?: boolean }): void { createTestRegistry([ { pluginId: "telegram", - plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }), + plugin: createOutboundTestPlugin({ + id: "telegram", + outbound: telegramOutbound, + messaging: { + parseExplicitTarget: ({ raw }) => { + const target = parseTelegramTarget(raw); + return { + to: target.chatId, + threadId: target.messageThreadId, + chatType: target.chatType === "unknown" ? undefined : target.chatType, + }; + }, + }, + }), source: "test", }, { diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index 9e7790e2065..2b500986f33 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -48,35 +48,18 @@ function prettifyInstanceName(name: string) { return normalized.replace(/\s+\(OpenClaw\)\s*$/i, "").trim() || normalized; } -type BonjourService = { - advertise: () => Promise; - destroy: () => Promise; - getFQDN: () => string; - getHostname: () => string; - getPort: () => number; - on: (event: string, listener: (...args: unknown[]) => void) => unknown; - serviceState: string; -}; +type BonjourService = import("@homebridge/ciao").CiaoService; +type BonjourResponder = import("@homebridge/ciao").Responder; +type BonjourServiceState = BonjourService["serviceState"]; type BonjourCycle = { - responder: { - createService: (options: { - name: string; - type: string; - protocol: unknown; - port: number; - domain: string; - hostname: string; - txt: Record; - }) => unknown; - shutdown: () => Promise; - }; + responder: BonjourResponder; services: Array<{ label: string; svc: BonjourService }>; cleanupUnhandledRejection?: () => void; }; type ServiceStateTracker = { - state: string; + state: BonjourServiceState | "unknown"; sinceMs: number; }; @@ -271,7 +254,8 @@ export async function startGatewayBonjourAdvertiser( const updateStateTrackers = (services: Array<{ label: string; svc: BonjourService }>) => { const now = Date.now(); for (const { label, svc } of services) { - const nextState = typeof svc.serviceState === "string" ? svc.serviceState : "unknown"; + const nextState: BonjourServiceState | "unknown" = + typeof svc.serviceState === "string" ? svc.serviceState : "unknown"; const current = stateTracker.get(label); if (!current || current.state !== nextState) { stateTracker.set(label, { state: nextState, sinceMs: now }); diff --git a/src/infra/outbound/channel-adapters.test.ts b/src/infra/outbound/channel-adapters.test.ts index d8a01aadb2b..ca39b403226 100644 --- a/src/infra/outbound/channel-adapters.test.ts +++ b/src/infra/outbound/channel-adapters.test.ts @@ -1,9 +1,18 @@ import { Separator, TextDisplay } from "@buape/carbon"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { discordPlugin } from "../../../extensions/discord/src/channel.js"; import { DiscordUiContainer } from "../../../extensions/discord/src/ui.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { getChannelMessageAdapter } from "./channel-adapters.js"; describe("getChannelMessageAdapter", () => { + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), + ); + }); + it("returns the default adapter for non-discord channels", () => { expect(getChannelMessageAdapter("telegram")).toEqual({ supportsComponentsV2: false, diff --git a/src/infra/outbound/outbound-policy.test.ts b/src/infra/outbound/outbound-policy.test.ts index fd19649c345..43e71afb923 100644 --- a/src/infra/outbound/outbound-policy.test.ts +++ b/src/infra/outbound/outbound-policy.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { discordPlugin } from "../../../extensions/discord/src/channel.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { applyCrossContextDecoration, buildCrossContextDecoration, @@ -23,6 +26,12 @@ const discordConfig = { } as OpenClawConfig; describe("outbound policy helpers", () => { + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), + ); + }); + it("allows cross-provider sends when enabled", () => { const cfg = { ...slackConfig, diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index 9e58d5c6c05..5c2303d7154 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -2,8 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { discordPlugin } from "../../../extensions/discord/src/channel.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { typedCases } from "../../test-utils/typed-cases.js"; import { ackDelivery, @@ -39,6 +42,12 @@ import { } from "./payloads.js"; import { runResolveOutboundTargetCoreTests } from "./targets.shared-test.js"; +beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), + ); +}); + describe("delivery-queue", () => { let tmpDir: string; let fixtureRoot = ""; From d7ab1a6c7cc6a479db379d086016f6f0cfc3a4f9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:54:37 -0700 Subject: [PATCH 068/133] Tests: add provider registry contract suite --- .../contracts/registry.contract.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/plugins/contracts/registry.contract.test.ts diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts new file mode 100644 index 00000000000..a87261fe58c --- /dev/null +++ b/src/plugins/contracts/registry.contract.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { providerContractRegistry, webSearchProviderContractRegistry } from "./registry.js"; + +function findProviderIdsForPlugin(pluginId: string) { + return providerContractRegistry + .filter((entry) => entry.pluginId === pluginId) + .map((entry) => entry.provider.id) + .toSorted((left, right) => left.localeCompare(right)); +} + +function findWebSearchIdsForPlugin(pluginId: string) { + return webSearchProviderContractRegistry + .filter((entry) => entry.pluginId === pluginId) + .map((entry) => entry.provider.id) + .toSorted((left, right) => left.localeCompare(right)); +} + +describe("plugin contract registry", () => { + it("does not duplicate bundled provider ids", () => { + const ids = providerContractRegistry.map((entry) => entry.provider.id); + expect(ids).toEqual([...new Set(ids)]); + }); + + it("does not duplicate bundled web search provider ids", () => { + const ids = webSearchProviderContractRegistry.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"]); + expect(findProviderIdsForPlugin("openai")).toEqual(["openai", "openai-codex"]); + }); + + it("keeps bundled web search ownership explicit", () => { + expect(findWebSearchIdsForPlugin("brave")).toEqual(["brave"]); + expect(findWebSearchIdsForPlugin("firecrawl")).toEqual(["firecrawl"]); + expect(findWebSearchIdsForPlugin("google")).toEqual(["gemini"]); + expect(findWebSearchIdsForPlugin("moonshot")).toEqual(["kimi"]); + expect(findWebSearchIdsForPlugin("perplexity")).toEqual(["perplexity"]); + expect(findWebSearchIdsForPlugin("xai")).toEqual(["grok"]); + }); +}); From 10cd276641aaf0d37ed90c6cd5ea6a7b87e72d96 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:54:51 -0700 Subject: [PATCH 069/133] Tests: relax provider auth hint contract --- src/plugins/contracts/suites.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/contracts/suites.ts b/src/plugins/contracts/suites.ts index 22898242855..9bc0d8ff8f6 100644 --- a/src/plugins/contracts/suites.ts +++ b/src/plugins/contracts/suites.ts @@ -26,7 +26,9 @@ export function installProviderPluginContractSuite(params: { provider: ProviderP for (const method of provider.auth) { expect(method.id.trim()).not.toBe(""); expect(method.label.trim()).not.toBe(""); - expect(method.hint.trim()).not.toBe(""); + if (method.hint !== undefined) { + expect(method.hint.trim()).not.toBe(""); + } expect(typeof method.run).toBe("function"); } }); From 476d948732d0269206fadf78d3463ce7df30e078 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:56:08 -0700 Subject: [PATCH 070/133] !refactor(browser): remove Chrome extension path and add MCP doctor migration (#47893) * Browser: replace extension path with Chrome MCP * Browser: clarify relay stub and doctor checks * Docs: mark browser MCP migration as breaking * Browser: reject unsupported profile drivers * Browser: accept clawd alias on profile create * Doctor: narrow legacy browser driver migration --- CHANGELOG.md | 4 + assets/chrome-extension/README.md | 23 - assets/chrome-extension/background-utils.js | 64 - assets/chrome-extension/background.js | 1025 -------------- assets/chrome-extension/manifest.json | 25 - assets/chrome-extension/options-validation.js | 57 - assets/chrome-extension/options.html | 200 --- assets/chrome-extension/options.js | 74 - docs/.generated/config-baseline.json | 16 +- docs/.generated/config-baseline.jsonl | 3 +- docs/cli/browser.md | 25 +- docs/cli/docs.md | 2 +- docs/docs.json | 2 - docs/gateway/configuration-reference.md | 4 +- docs/gateway/doctor.md | 30 + docs/gateway/security/index.md | 5 +- docs/gateway/troubleshooting.md | 5 +- docs/help/faq.md | 34 +- docs/help/troubleshooting.md | 5 +- docs/install/docker.md | 1 + docs/tools/browser-linux-troubleshooting.md | 11 +- docs/tools/browser-login.md | 1 - ...wsl2-windows-remote-cdp-troubleshooting.md | 67 +- docs/tools/browser.md | 108 +- docs/tools/chrome-extension.md | 203 --- docs/tools/index.md | 3 +- scripts/test-parallel.mjs | 1 - src/agents/tools/browser-tool.actions.ts | 20 +- src/agents/tools/browser-tool.test.ts | 30 +- src/agents/tools/browser-tool.ts | 6 +- src/browser/browser-utils.test.ts | 30 +- src/browser/cdp.helpers.ts | 4 +- .../chrome-extension-background-utils.test.ts | 133 -- src/browser/chrome-extension-manifest.test.ts | 29 - ...hrome-extension-options-validation.test.ts | 113 -- src/browser/chrome-mcp.ts | 2 +- src/browser/chrome.executables.ts | 90 ++ src/browser/client.ts | 6 +- src/browser/config.test.ts | 11 - src/browser/config.ts | 13 +- .../extension-relay-auth.secretref.test.ts | 117 -- src/browser/extension-relay-auth.test.ts | 131 -- src/browser/extension-relay-auth.ts | 113 -- src/browser/extension-relay.bind-host.test.ts | 49 - src/browser/extension-relay.test.ts | 1224 ----------------- src/browser/extension-relay.ts | 1068 -------------- src/browser/profile-capabilities.ts | 40 +- src/browser/profiles-service.test.ts | 31 - src/browser/profiles-service.ts | 25 +- ...ge-for-targetid.extension-fallback.test.ts | 26 +- src/browser/pw-session.page-cdp.test.ts | 63 +- src/browser/pw-session.page-cdp.ts | 48 - src/browser/pw-session.ts | 21 +- .../routes/agent.snapshot.plan.test.ts | 10 +- src/browser/routes/basic.ts | 21 +- src/browser/server-context.availability.ts | 39 +- ...-tab-available.prefers-last-target.test.ts | 178 --- src/browser/server-context.reset.test.ts | 22 - src/browser/server-context.reset.ts | 5 - src/browser/server-context.selection.ts | 23 +- src/browser/server-lifecycle.test.ts | 92 +- src/browser/server-lifecycle.ts | 28 +- .../server.control-server.test-harness.ts | 2 +- ...s-open-profile-unknown-returns-404.test.ts | 29 +- src/cli/browser-cli-extension.test.ts | 190 --- src/cli/browser-cli-extension.ts | 140 -- src/cli/browser-cli-manage.ts | 22 +- src/cli/browser-cli.ts | 2 - src/commands/doctor-browser.test.ts | 96 ++ src/commands/doctor-browser.ts | 108 ++ src/commands/doctor-config-flow.test.ts | 54 + src/commands/doctor-legacy-config.ts | 62 + src/commands/doctor.fast-path-mocks.ts | 4 + src/commands/doctor.ts | 2 + src/config/schema.help.quality.test.ts | 2 +- src/config/schema.help.ts | 4 +- src/config/schema.labels.ts | 1 - src/config/types.browser.ts | 8 +- src/config/zod-schema.ts | 8 +- src/node-host/invoke-browser.test.ts | 6 +- 80 files changed, 644 insertions(+), 5955 deletions(-) delete mode 100644 assets/chrome-extension/README.md delete mode 100644 assets/chrome-extension/background-utils.js delete mode 100644 assets/chrome-extension/background.js delete mode 100644 assets/chrome-extension/manifest.json delete mode 100644 assets/chrome-extension/options-validation.js delete mode 100644 assets/chrome-extension/options.html delete mode 100644 assets/chrome-extension/options.js delete mode 100644 docs/tools/chrome-extension.md delete mode 100644 src/browser/chrome-extension-background-utils.test.ts delete mode 100644 src/browser/chrome-extension-manifest.test.ts delete mode 100644 src/browser/chrome-extension-options-validation.test.ts delete mode 100644 src/browser/extension-relay-auth.secretref.test.ts delete mode 100644 src/browser/extension-relay-auth.test.ts delete mode 100644 src/browser/extension-relay-auth.ts delete mode 100644 src/browser/extension-relay.bind-host.test.ts delete mode 100644 src/browser/extension-relay.test.ts delete mode 100644 src/browser/extension-relay.ts delete mode 100644 src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts delete mode 100644 src/cli/browser-cli-extension.test.ts delete mode 100644 src/cli/browser-cli-extension.ts create mode 100644 src/commands/doctor-browser.test.ts create mode 100644 src/commands/doctor-browser.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a21ff47279b..384fcffc330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ Docs: https://docs.openclaw.ai - Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) - Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819) +### Breaking + +- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. Thanks @vincentkoc. + ### Fixes - Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles. diff --git a/assets/chrome-extension/README.md b/assets/chrome-extension/README.md deleted file mode 100644 index 4ee072c1f2b..00000000000 --- a/assets/chrome-extension/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# OpenClaw Chrome Extension (Browser Relay) - -Purpose: attach OpenClaw to an existing Chrome tab so the Gateway can automate it (via the local CDP relay server). - -## Dev / load unpacked - -1. Build/run OpenClaw Gateway with browser control enabled. -2. Ensure the relay server is reachable at `http://127.0.0.1:18792/` (default). -3. Install the extension to a stable path: - - ```bash - openclaw browser extension install - openclaw browser extension path - ``` - -4. Chrome → `chrome://extensions` → enable “Developer mode”. -5. “Load unpacked” → select the path printed above. -6. Pin the extension. Click the icon on a tab to attach/detach. - -## Options - -- `Relay port`: defaults to `18792`. -- `Gateway token`: required. Set this to `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). diff --git a/assets/chrome-extension/background-utils.js b/assets/chrome-extension/background-utils.js deleted file mode 100644 index 82d43359c0a..00000000000 --- a/assets/chrome-extension/background-utils.js +++ /dev/null @@ -1,64 +0,0 @@ -export function reconnectDelayMs( - attempt, - opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random }, -) { - const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000; - const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000; - const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000; - const random = typeof opts.random === "function" ? opts.random : Math.random; - const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0); - const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs); - return backoff + Math.max(0, jitterMs) * random(); -} - -export async function deriveRelayToken(gatewayToken, port) { - const enc = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - enc.encode(gatewayToken), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign( - "HMAC", - key, - enc.encode(`openclaw-extension-relay-v1:${port}`), - ); - return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); -} - -export async function buildRelayWsUrl(port, gatewayToken) { - const token = String(gatewayToken || "").trim(); - if (!token) { - throw new Error( - "Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)", - ); - } - const relayToken = await deriveRelayToken(token, port); - return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(relayToken)}`; -} - -export function isRetryableReconnectError(err) { - const message = err instanceof Error ? err.message : String(err || ""); - if (message.includes("Missing gatewayToken")) { - return false; - } - return true; -} - -export function isMissingTabError(err) { - const message = (err instanceof Error ? err.message : String(err || "")).toLowerCase(); - return ( - message.includes("no tab with id") || - message.includes("no tab with given id") || - message.includes("tab not found") - ); -} - -export function isLastRemainingTab(allTabs, tabIdToClose) { - if (!Array.isArray(allTabs)) { - return true; - } - return allTabs.filter((tab) => tab && tab.id !== tabIdToClose).length === 0; -} diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js deleted file mode 100644 index 9031a156489..00000000000 --- a/assets/chrome-extension/background.js +++ /dev/null @@ -1,1025 +0,0 @@ -import { - buildRelayWsUrl, - isLastRemainingTab, - isMissingTabError, - isRetryableReconnectError, - reconnectDelayMs, -} from './background-utils.js' - -const DEFAULT_PORT = 18792 - -const BADGE = { - on: { text: 'ON', color: '#FF5A36' }, - off: { text: '', color: '#000000' }, - connecting: { text: '…', color: '#F59E0B' }, - error: { text: '!', color: '#B91C1C' }, -} - -/** @type {WebSocket|null} */ -let relayWs = null -/** @type {Promise|null} */ -let relayConnectPromise = null -let relayGatewayToken = '' -/** @type {string|null} */ -let relayConnectRequestId = null - -let nextSession = 1 - -/** @type {Map} */ -const tabs = new Map() -/** @type {Map} */ -const tabBySession = new Map() -/** @type {Map} */ -const childSessionToTab = new Map() - -/** @type {Mapvoid, reject:(e:Error)=>void}>} */ -const pending = new Map() - -// Per-tab operation locks prevent double-attach races. -/** @type {Set} */ -const tabOperationLocks = new Set() - -// Tabs currently in a detach/re-attach cycle after navigation. -/** @type {Set} */ -const reattachPending = new Set() - -// Reconnect state for exponential backoff. -let reconnectAttempt = 0 -let reconnectTimer = null - -const TAB_VALIDATION_ATTEMPTS = 2 -const TAB_VALIDATION_RETRY_DELAY_MS = 1000 - -function nowStack() { - try { - return new Error().stack || '' - } catch { - return '' - } -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -async function validateAttachedTab(tabId) { - try { - await chrome.tabs.get(tabId) - } catch { - return false - } - - for (let attempt = 0; attempt < TAB_VALIDATION_ATTEMPTS; attempt++) { - try { - await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { - expression: '1', - returnByValue: true, - }) - return true - } catch (err) { - if (isMissingTabError(err)) { - return false - } - if (attempt < TAB_VALIDATION_ATTEMPTS - 1) { - await sleep(TAB_VALIDATION_RETRY_DELAY_MS) - } - } - } - - return false -} - -async function getRelayPort() { - const stored = await chrome.storage.local.get(['relayPort']) - const raw = stored.relayPort - const n = Number.parseInt(String(raw || ''), 10) - if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT - return n -} - -async function getGatewayToken() { - const stored = await chrome.storage.local.get(['gatewayToken']) - const token = String(stored.gatewayToken || '').trim() - return token || '' -} - -function setBadge(tabId, kind) { - const cfg = BADGE[kind] - void chrome.action.setBadgeText({ tabId, text: cfg.text }) - void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color }) - void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {}) -} - -// Persist attached tab state to survive MV3 service worker restarts. -async function persistState() { - try { - const tabEntries = [] - for (const [tabId, tab] of tabs.entries()) { - if (tab.state === 'connected' && tab.sessionId && tab.targetId) { - tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder }) - } - } - await chrome.storage.session.set({ - persistedTabs: tabEntries, - nextSession, - }) - } catch { - // chrome.storage.session may not be available in all contexts. - } -} - -// Rehydrate tab state on service worker startup. Fast path — just restores -// maps and badges. Relay reconnect happens separately in background. -async function rehydrateState() { - try { - const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession']) - if (stored.nextSession) { - nextSession = Math.max(nextSession, stored.nextSession) - } - const entries = stored.persistedTabs || [] - // Phase 1: optimistically restore state and badges. - for (const entry of entries) { - tabs.set(entry.tabId, { - state: 'connected', - sessionId: entry.sessionId, - targetId: entry.targetId, - attachOrder: entry.attachOrder, - }) - tabBySession.set(entry.sessionId, entry.tabId) - setBadge(entry.tabId, 'on') - } - // Retry once so transient busy/navigation states do not permanently drop - // a still-attached tab after a service worker restart. - for (const entry of entries) { - const valid = await validateAttachedTab(entry.tabId) - if (!valid) { - tabs.delete(entry.tabId) - tabBySession.delete(entry.sessionId) - setBadge(entry.tabId, 'off') - } - } - } catch { - // Ignore rehydration errors. - } -} - -async function ensureRelayConnection() { - if (relayWs && relayWs.readyState === WebSocket.OPEN) return - if (relayConnectPromise) return await relayConnectPromise - - relayConnectPromise = (async () => { - const port = await getRelayPort() - const gatewayToken = await getGatewayToken() - const httpBase = `http://127.0.0.1:${port}` - const wsUrl = await buildRelayWsUrl(port, gatewayToken) - - // Fast preflight: is the relay server up? - try { - await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) }) - } catch (err) { - throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) - } - - const ws = new WebSocket(wsUrl) - relayWs = ws - relayGatewayToken = gatewayToken - // Bind message handler before open so an immediate first frame (for example - // gateway connect.challenge) cannot be missed. - ws.onmessage = (event) => { - if (ws !== relayWs) return - void whenReady(() => onRelayMessage(String(event.data || ''))) - } - - await new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000) - ws.onopen = () => { - clearTimeout(t) - resolve() - } - ws.onerror = () => { - clearTimeout(t) - reject(new Error('WebSocket connect failed')) - } - ws.onclose = (ev) => { - clearTimeout(t) - reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`)) - } - }) - - // Bind permanent handlers. Guard against stale socket: if this WS was - // replaced before its close fires, the handler is a no-op. - ws.onclose = () => { - if (ws !== relayWs) return - onRelayClosed('closed') - } - ws.onerror = () => { - if (ws !== relayWs) return - onRelayClosed('error') - } - })() - - try { - await relayConnectPromise - reconnectAttempt = 0 - } finally { - relayConnectPromise = null - } -} - -// Relay closed — update badges, reject pending requests, auto-reconnect. -// Debugger sessions are kept alive so they survive transient WS drops. -function onRelayClosed(reason) { - relayWs = null - relayGatewayToken = '' - relayConnectRequestId = null - - for (const [id, p] of pending.entries()) { - pending.delete(id) - p.reject(new Error(`Relay disconnected (${reason})`)) - } - - reattachPending.clear() - - for (const [tabId, tab] of tabs.entries()) { - if (tab.state === 'connected') { - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: relay reconnecting…', - }) - } - } - - scheduleReconnect() -} - -function scheduleReconnect() { - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - - const delay = reconnectDelayMs(reconnectAttempt) - reconnectAttempt++ - - console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`) - - reconnectTimer = setTimeout(async () => { - reconnectTimer = null - try { - await ensureRelayConnection() - reconnectAttempt = 0 - console.log('Reconnected successfully') - await reannounceAttachedTabs() - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`) - if (!isRetryableReconnectError(err)) { - return - } - scheduleReconnect() - } - }, delay) -} - -function cancelReconnect() { - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - reconnectAttempt = 0 -} - -// Re-announce all attached tabs to the relay after reconnect. -async function reannounceAttachedTabs() { - for (const [tabId, tab] of tabs.entries()) { - if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue - - // Retry once here as well; reconnect races can briefly make an otherwise - // healthy tab look unavailable. - const valid = await validateAttachedTab(tabId) - if (!valid) { - tabs.delete(tabId) - if (tab.sessionId) tabBySession.delete(tab.sessionId) - setBadge(tabId, 'off') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay (click to attach/detach)', - }) - continue - } - - // Send fresh attach event to relay. - // Split into two try-catch blocks so debugger failures and relay send - // failures are handled independently. Previously, a relay send failure - // would fall into the outer catch and set the badge to 'on' even though - // the relay had no record of the tab — causing every subsequent browser - // tool call to fail with "no tab connected" until the next reconnect cycle. - let targetInfo - try { - const info = /** @type {any} */ ( - await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo') - ) - targetInfo = info?.targetInfo - } catch { - // Target.getTargetInfo failed. Preserve at least targetId from - // cached tab state so relay receives a stable identifier. - targetInfo = tab.targetId ? { targetId: tab.targetId } : undefined - } - - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.attachedToTarget', - params: { - sessionId: tab.sessionId, - targetInfo: { ...targetInfo, attached: true }, - waitingForDebugger: false, - }, - }, - }) - - setBadge(tabId, 'on') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: attached (click to detach)', - }) - } catch { - // Relay send failed (e.g. WS closed in the gap between ensureRelayConnection - // resolving and this loop executing). The tab is still valid — leave badge - // as 'connecting' so the reconnect/keepalive cycle will retry rather than - // showing a false-positive 'on' that hides the broken state from the user. - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: relay reconnecting…', - }) - } - } - - await persistState() -} - -function sendToRelay(payload) { - const ws = relayWs - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error('Relay not connected') - } - ws.send(JSON.stringify(payload)) -} - -function ensureGatewayHandshakeStarted(payload) { - if (relayConnectRequestId) return - const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : '' - relayConnectRequestId = `ext-connect-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` - sendToRelay({ - type: 'req', - id: relayConnectRequestId, - method: 'connect', - params: { - minProtocol: 3, - maxProtocol: 3, - client: { - id: 'chrome-relay-extension', - version: '1.0.0', - platform: 'chrome-extension', - mode: 'webchat', - }, - role: 'operator', - scopes: ['operator.read', 'operator.write'], - caps: [], - commands: [], - nonce: nonce || undefined, - auth: relayGatewayToken ? { token: relayGatewayToken } : undefined, - }, - }) -} - -async function maybeOpenHelpOnce() { - try { - const stored = await chrome.storage.local.get(['helpOnErrorShown']) - if (stored.helpOnErrorShown === true) return - await chrome.storage.local.set({ helpOnErrorShown: true }) - await chrome.runtime.openOptionsPage() - } catch { - // ignore - } -} - -function requestFromRelay(command) { - const id = command.id - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pending.delete(id) - reject(new Error('Relay request timeout (30s)')) - }, 30000) - pending.set(id, { - resolve: (v) => { clearTimeout(timer); resolve(v) }, - reject: (e) => { clearTimeout(timer); reject(e) }, - }) - try { - sendToRelay(command) - } catch (err) { - clearTimeout(timer) - pending.delete(id) - reject(err instanceof Error ? err : new Error(String(err))) - } - }) -} - -async function onRelayMessage(text) { - /** @type {any} */ - let msg - try { - msg = JSON.parse(text) - } catch { - return - } - - if (msg && msg.type === 'event' && msg.event === 'connect.challenge') { - try { - ensureGatewayHandshakeStarted(msg.payload) - } catch (err) { - console.warn('gateway connect handshake start failed', err instanceof Error ? err.message : String(err)) - relayConnectRequestId = null - const ws = relayWs - if (ws && ws.readyState === WebSocket.OPEN) { - ws.close(1008, 'gateway connect failed') - } - } - return - } - - if (msg && msg.type === 'res' && relayConnectRequestId && msg.id === relayConnectRequestId) { - relayConnectRequestId = null - if (!msg.ok) { - const detail = msg?.error?.message || msg?.error || 'gateway connect failed' - console.warn('gateway connect handshake rejected', String(detail)) - const ws = relayWs - if (ws && ws.readyState === WebSocket.OPEN) { - ws.close(1008, 'gateway connect failed') - } - } - return - } - - if (msg && msg.method === 'ping') { - try { - sendToRelay({ method: 'pong' }) - } catch { - // ignore - } - return - } - - if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) { - const p = pending.get(msg.id) - if (!p) return - pending.delete(msg.id) - if (msg.error) p.reject(new Error(String(msg.error))) - else p.resolve(msg.result) - return - } - - if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') { - try { - const result = await handleForwardCdpCommand(msg) - sendToRelay({ id: msg.id, result }) - } catch (err) { - sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) }) - } - } -} - -function getTabBySessionId(sessionId) { - const direct = tabBySession.get(sessionId) - if (direct) return { tabId: direct, kind: 'main' } - const child = childSessionToTab.get(sessionId) - if (child) return { tabId: child, kind: 'child' } - return null -} - -function getTabByTargetId(targetId) { - for (const [tabId, tab] of tabs.entries()) { - if (tab.targetId === targetId) return tabId - } - return null -} - -async function attachTab(tabId, opts = {}) { - const debuggee = { tabId } - await chrome.debugger.attach(debuggee, '1.3') - await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {}) - - const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')) - const targetInfo = info?.targetInfo - const targetId = String(targetInfo?.targetId || '').trim() - if (!targetId) { - throw new Error('Target.getTargetInfo returned no targetId') - } - - const sid = nextSession++ - const sessionId = `cb-tab-${sid}` - const attachOrder = sid - - tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder }) - tabBySession.set(sessionId, tabId) - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: attached (click to detach)', - }) - - if (!opts.skipAttachedEvent) { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.attachedToTarget', - params: { - sessionId, - targetInfo: { ...targetInfo, attached: true }, - waitingForDebugger: false, - }, - }, - }) - } - - setBadge(tabId, 'on') - await persistState() - - return { sessionId, targetId } -} - -async function detachTab(tabId, reason) { - const tab = tabs.get(tabId) - - // Send detach events for child sessions first. - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: childSessionId, reason: 'parent_detached' }, - }, - }) - } catch { - // Relay may be down. - } - childSessionToTab.delete(childSessionId) - } - } - - // Send detach event for main session. - if (tab?.sessionId && tab?.targetId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: tab.sessionId, targetId: tab.targetId, reason }, - }, - }) - } catch { - // Relay may be down. - } - } - - if (tab?.sessionId) tabBySession.delete(tab.sessionId) - tabs.delete(tabId) - - try { - await chrome.debugger.detach({ tabId }) - } catch { - // May already be detached. - } - - setBadge(tabId, 'off') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay (click to attach/detach)', - }) - - await persistState() -} - -async function connectOrToggleForActiveTab() { - const [active] = await chrome.tabs.query({ active: true, currentWindow: true }) - const tabId = active?.id - if (!tabId) return - - // Prevent concurrent operations on the same tab. - if (tabOperationLocks.has(tabId)) return - tabOperationLocks.add(tabId) - - try { - if (reattachPending.has(tabId)) { - reattachPending.delete(tabId) - setBadge(tabId, 'off') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay (click to attach/detach)', - }) - return - } - - const existing = tabs.get(tabId) - if (existing?.state === 'connected') { - await detachTab(tabId, 'toggle') - return - } - - // User is manually connecting — cancel any pending reconnect. - cancelReconnect() - - tabs.set(tabId, { state: 'connecting' }) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: connecting to local relay…', - }) - - try { - await ensureRelayConnection() - await attachTab(tabId) - } catch (err) { - tabs.delete(tabId) - setBadge(tabId, 'error') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: relay not running (open options for setup)', - }) - void maybeOpenHelpOnce() - const message = err instanceof Error ? err.message : String(err) - console.warn('attach failed', message, nowStack()) - } - } finally { - tabOperationLocks.delete(tabId) - } -} - -async function handleForwardCdpCommand(msg) { - const method = String(msg?.params?.method || '').trim() - const params = msg?.params?.params || undefined - const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined - - const bySession = sessionId ? getTabBySessionId(sessionId) : null - const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined - const tabId = - bySession?.tabId || - (targetId ? getTabByTargetId(targetId) : null) || - (() => { - for (const [id, tab] of tabs.entries()) { - if (tab.state === 'connected') return id - } - return null - })() - - if (!tabId) throw new Error(`No attached tab for method ${method}`) - - /** @type {chrome.debugger.DebuggerSession} */ - const debuggee = { tabId } - - if (method === 'Runtime.enable') { - try { - await chrome.debugger.sendCommand(debuggee, 'Runtime.disable') - await new Promise((r) => setTimeout(r, 50)) - } catch { - // ignore - } - return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params) - } - - if (method === 'Target.createTarget') { - const url = typeof params?.url === 'string' ? params.url : 'about:blank' - const tab = await chrome.tabs.create({ url, active: false }) - if (!tab.id) throw new Error('Failed to create tab') - await new Promise((r) => setTimeout(r, 100)) - const attached = await attachTab(tab.id) - return { targetId: attached.targetId } - } - - if (method === 'Target.closeTarget') { - const target = typeof params?.targetId === 'string' ? params.targetId : '' - const toClose = target ? getTabByTargetId(target) : tabId - if (!toClose) return { success: false } - try { - const allTabs = await chrome.tabs.query({}) - if (isLastRemainingTab(allTabs, toClose)) { - console.warn('Refusing to close the last tab: this would kill the browser process') - return { success: false, error: 'Cannot close the last tab' } - } - await chrome.tabs.remove(toClose) - } catch { - return { success: false } - } - return { success: true } - } - - if (method === 'Target.activateTarget') { - const target = typeof params?.targetId === 'string' ? params.targetId : '' - const toActivate = target ? getTabByTargetId(target) : tabId - if (!toActivate) return {} - const tab = await chrome.tabs.get(toActivate).catch(() => null) - if (!tab) return {} - if (tab.windowId) { - await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) - } - await chrome.tabs.update(toActivate, { active: true }).catch(() => {}) - return {} - } - - const tabState = tabs.get(tabId) - const mainSessionId = tabState?.sessionId - const debuggerSession = - sessionId && mainSessionId && sessionId !== mainSessionId - ? { ...debuggee, sessionId } - : debuggee - - return await chrome.debugger.sendCommand(debuggerSession, method, params) -} - -function onDebuggerEvent(source, method, params) { - const tabId = source.tabId - if (!tabId) return - const tab = tabs.get(tabId) - if (!tab?.sessionId) return - - if (method === 'Target.attachedToTarget' && params?.sessionId) { - childSessionToTab.set(String(params.sessionId), tabId) - } - - if (method === 'Target.detachedFromTarget' && params?.sessionId) { - childSessionToTab.delete(String(params.sessionId)) - } - - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - sessionId: source.sessionId || tab.sessionId, - method, - params, - }, - }) - } catch { - // Relay may be down. - } -} - -async function onDebuggerDetach(source, reason) { - const tabId = source.tabId - if (!tabId) return - if (!tabs.has(tabId)) return - - // User explicitly cancelled or DevTools replaced the connection — respect their intent - if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { - void detachTab(tabId, reason) - return - } - - // Check if tab still exists — distinguishes navigation from tab close - let tabInfo - try { - tabInfo = await chrome.tabs.get(tabId) - } catch { - // Tab is gone (closed) — normal cleanup - void detachTab(tabId, reason) - return - } - - if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) { - void detachTab(tabId, reason) - return - } - - if (reattachPending.has(tabId)) return - - const oldTab = tabs.get(tabId) - const oldSessionId = oldTab?.sessionId - const oldTargetId = oldTab?.targetId - - if (oldSessionId) tabBySession.delete(oldSessionId) - tabs.delete(tabId) - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) childSessionToTab.delete(childSessionId) - } - - if (oldSessionId && oldTargetId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' }, - }, - }) - } catch { - // Relay may be down. - } - } - - reattachPending.add(tabId) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: re-attaching after navigation…', - }) - - // Extend re-attach window from 2.5 s to ~7.7 s (5 attempts). - // SPAs and pages with heavy JS can take >2.5 s before the Chrome debugger - // is attachable, causing all three original attempts to fail and leaving - // the badge permanently off after every navigation. - const delays = [200, 500, 1000, 2000, 4000] - for (let attempt = 0; attempt < delays.length; attempt++) { - await new Promise((r) => setTimeout(r, delays[attempt])) - - if (!reattachPending.has(tabId)) return - - try { - await chrome.tabs.get(tabId) - } catch { - reattachPending.delete(tabId) - setBadge(tabId, 'off') - return - } - - const relayUp = relayWs && relayWs.readyState === WebSocket.OPEN - - try { - // When relay is down, still attach the debugger but skip sending the - // relay event. reannounceAttachedTabs() will notify the relay once it - // reconnects, so the tab stays tracked across transient relay drops. - await attachTab(tabId, { skipAttachedEvent: !relayUp }) - reattachPending.delete(tabId) - if (!relayUp) { - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: attached, waiting for relay reconnect…', - }) - } - return - } catch { - // continue retries - } - } - - reattachPending.delete(tabId) - setBadge(tabId, 'off') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: re-attach failed (click to retry)', - }) -} - -// Tab lifecycle listeners — clean up stale entries. -chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { - reattachPending.delete(tabId) - if (!tabs.has(tabId)) return - const tab = tabs.get(tabId) - if (tab?.sessionId) tabBySession.delete(tab.sessionId) - tabs.delete(tabId) - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) childSessionToTab.delete(childSessionId) - } - if (tab?.sessionId && tab?.targetId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' }, - }, - }) - } catch { - // Relay may be down. - } - } - void persistState() -})) - -chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => { - const tab = tabs.get(removedTabId) - if (!tab) return - tabs.delete(removedTabId) - tabs.set(addedTabId, tab) - if (tab.sessionId) { - tabBySession.set(tab.sessionId, addedTabId) - } - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === removedTabId) { - childSessionToTab.set(childSessionId, addedTabId) - } - } - setBadge(addedTabId, 'on') - void persistState() -})) - -// Register debugger listeners at module scope so detach/event handling works -// even when the relay WebSocket is down. -chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args))) -chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args))) - -chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab())) - -// Refresh badge after navigation completes — service worker may have restarted -// during navigation, losing ephemeral badge state. -chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => { - if (frameId !== 0) return - const tab = tabs.get(tabId) - if (tab?.state === 'connected') { - setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') - } -})) - -// Refresh badge when user switches to an attached tab. -chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => { - const tab = tabs.get(tabId) - if (tab?.state === 'connected') { - setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') - } -})) - -chrome.runtime.onInstalled.addListener(() => { - void chrome.runtime.openOptionsPage() -}) - -// MV3 keepalive via chrome.alarms — more reliable than setInterval across -// service worker restarts. Checks relay health and refreshes badges. -chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 }) - -chrome.alarms.onAlarm.addListener(async (alarm) => { - if (alarm.name !== 'relay-keepalive') return - await initPromise - - if (tabs.size === 0) return - - // Refresh badges (ephemeral in MV3). - for (const [tabId, tab] of tabs.entries()) { - if (tab.state === 'connected') { - setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') - } - } - - // If relay is down and no reconnect is in progress, trigger one. - if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { - if (!relayConnectPromise && !reconnectTimer) { - console.log('Keepalive: WebSocket unhealthy, triggering reconnect') - await ensureRelayConnection().catch(() => { - // ensureRelayConnection may throw without triggering onRelayClosed - // (e.g. preflight fetch fails before WS is created), so ensure - // reconnect is always scheduled on failure. - if (!reconnectTimer) { - scheduleReconnect() - } - }) - } - } -}) - -// Rehydrate state on service worker startup. Split: rehydration is the gate -// (fast), relay reconnect runs in background (slow, non-blocking). -const initPromise = rehydrateState() - -initPromise.then(() => { - if (tabs.size > 0) { - ensureRelayConnection().then(() => { - reconnectAttempt = 0 - return reannounceAttachedTabs() - }).catch(() => { - scheduleReconnect() - }) - } -}) - -// Shared gate: all state-dependent handlers await this before accessing maps. -async function whenReady(fn) { - await initPromise - return fn() -} - -// Relay check handler for the options page. The service worker has -// host_permissions and bypasses CORS preflight, so the options page -// delegates token-validation requests here. -chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type !== 'relayCheck') return false - const { url, token } = msg - const headers = token ? { 'x-openclaw-relay-token': token } : {} - fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) - .then(async (res) => { - const contentType = String(res.headers.get('content-type') || '') - let json = null - if (contentType.includes('application/json')) { - try { - json = await res.json() - } catch { - json = null - } - } - sendResponse({ status: res.status, ok: res.ok, contentType, json }) - }) - .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) })) - return true -}) diff --git a/assets/chrome-extension/manifest.json b/assets/chrome-extension/manifest.json deleted file mode 100644 index 62038276cd7..00000000000 --- a/assets/chrome-extension/manifest.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "manifest_version": 3, - "name": "OpenClaw Browser Relay", - "version": "0.1.0", - "description": "Attach OpenClaw to your existing Chrome tab via a local CDP relay server.", - "icons": { - "16": "icons/icon16.png", - "32": "icons/icon32.png", - "48": "icons/icon48.png", - "128": "icons/icon128.png" - }, - "permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"], - "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"], - "background": { "service_worker": "background.js", "type": "module" }, - "action": { - "default_title": "OpenClaw Browser Relay (click to attach/detach)", - "default_icon": { - "16": "icons/icon16.png", - "32": "icons/icon32.png", - "48": "icons/icon48.png", - "128": "icons/icon128.png" - } - }, - "options_ui": { "page": "options.html", "open_in_tab": true } -} diff --git a/assets/chrome-extension/options-validation.js b/assets/chrome-extension/options-validation.js deleted file mode 100644 index 53e2cd55014..00000000000 --- a/assets/chrome-extension/options-validation.js +++ /dev/null @@ -1,57 +0,0 @@ -const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).' - -function hasCdpVersionShape(data) { - return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data -} - -export function classifyRelayCheckResponse(res, port) { - if (!res) { - return { action: 'throw', error: 'No response from service worker' } - } - - if (res.status === 401) { - return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' } - } - - if (res.error) { - return { action: 'throw', error: res.error } - } - - if (!res.ok) { - return { action: 'throw', error: `HTTP ${res.status}` } - } - - const contentType = String(res.contentType || '') - if (!contentType.includes('application/json')) { - return { - action: 'status', - kind: 'error', - message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`, - } - } - - if (!hasCdpVersionShape(res.json)) { - return { - action: 'status', - kind: 'error', - message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`, - } - } - - return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` } -} - -export function classifyRelayCheckException(err, port) { - const message = String(err || '').toLowerCase() - if (message.includes('json') || message.includes('syntax')) { - return { - kind: 'error', - message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`, - } - } - - return { - kind: 'error', - message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - } -} diff --git a/assets/chrome-extension/options.html b/assets/chrome-extension/options.html deleted file mode 100644 index 17fc6a79eed..00000000000 --- a/assets/chrome-extension/options.html +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - OpenClaw Browser Relay - - - -
-
- -
-

OpenClaw Browser Relay

-

Click the toolbar button on a tab to attach / detach.

-
-
- -
-
-

Getting started

-

- If you see a red ! badge on the extension icon, the relay server is not reachable. - Start OpenClaw’s browser relay on this machine (Gateway or node host), then click the toolbar button again. -

-

- Full guide (install, remote Gateway, security): docs.openclaw.ai/tools/chrome-extension -

-
- -
-

Relay connection

- -
- -
- -
- - -
-
- Default port: 18792. Extension connects to: http://127.0.0.1:<port>/. - Gateway token must match gateway.auth.token (or OPENCLAW_GATEWAY_TOKEN). -
-
-
-
- - -
- - diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js deleted file mode 100644 index aa6fcc4901f..00000000000 --- a/assets/chrome-extension/options.js +++ /dev/null @@ -1,74 +0,0 @@ -import { deriveRelayToken } from './background-utils.js' -import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' - -const DEFAULT_PORT = 18792 - -function clampPort(value) { - const n = Number.parseInt(String(value || ''), 10) - if (!Number.isFinite(n)) return DEFAULT_PORT - if (n <= 0 || n > 65535) return DEFAULT_PORT - return n -} - -function updateRelayUrl(port) { - const el = document.getElementById('relay-url') - if (!el) return - el.textContent = `http://127.0.0.1:${port}/` -} - -function setStatus(kind, message) { - const status = document.getElementById('status') - if (!status) return - status.dataset.kind = kind || '' - status.textContent = message || '' -} - -async function checkRelayReachable(port, token) { - const url = `http://127.0.0.1:${port}/json/version` - const trimmedToken = String(token || '').trim() - if (!trimmedToken) { - setStatus('error', 'Gateway token required. Save your gateway token to connect.') - return - } - try { - const relayToken = await deriveRelayToken(trimmedToken, port) - // Delegate the fetch to the background service worker to bypass - // CORS preflight on the custom x-openclaw-relay-token header. - const res = await chrome.runtime.sendMessage({ - type: 'relayCheck', - url, - token: relayToken, - }) - const result = classifyRelayCheckResponse(res, port) - if (result.action === 'throw') throw new Error(result.error) - setStatus(result.kind, result.message) - } catch (err) { - const result = classifyRelayCheckException(err, port) - setStatus(result.kind, result.message) - } -} - -async function load() { - const stored = await chrome.storage.local.get(['relayPort', 'gatewayToken']) - const port = clampPort(stored.relayPort) - const token = String(stored.gatewayToken || '').trim() - document.getElementById('port').value = String(port) - document.getElementById('token').value = token - updateRelayUrl(port) - await checkRelayReachable(port, token) -} - -async function save() { - const portInput = document.getElementById('port') - const tokenInput = document.getElementById('token') - const port = clampPort(portInput.value) - const token = String(tokenInput.value || '').trim() - await chrome.storage.local.set({ relayPort: port, gatewayToken: token }) - portInput.value = String(port) - tokenInput.value = token - updateRelayUrl(port) - await checkRelayReachable(port, token) -} - -document.getElementById('save').addEventListener('click', () => void save()) -void load() diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index 8a30b9c6fde..d162ba7a92e 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -8035,21 +8035,7 @@ "storage" ], "label": "Browser Profile Driver", - "help": "Per-profile browser driver mode: \"openclaw\" (or legacy \"clawd\") or \"extension\" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.", - "hasChildren": false - }, - { - "path": "browser.relayBindHost", - "kind": "core", - "type": "string", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [ - "advanced" - ], - "label": "Browser Relay Bind Address", - "help": "Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.", + "help": "Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.", "hasChildren": false }, { diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index f8a5068394e..80b18c4cc4b 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -707,8 +707,7 @@ {"recordType":"path","path":"browser.profiles.*.cdpPort","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP Port","help":"Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.","hasChildren":false} {"recordType":"path","path":"browser.profiles.*.cdpUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP URL","help":"Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.","hasChildren":false} {"recordType":"path","path":"browser.profiles.*.color","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Accent Color","help":"Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.","hasChildren":false} -{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode: \"openclaw\" (or legacy \"clawd\") or \"extension\" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.","hasChildren":false} -{"recordType":"path","path":"browser.relayBindHost","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Browser Relay Bind Address","help":"Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.","hasChildren":false} +{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.","hasChildren":false} {"recordType":"path","path":"browser.remoteCdpHandshakeTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Handshake Timeout (ms)","help":"Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.","hasChildren":false} {"recordType":"path","path":"browser.remoteCdpTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Timeout (ms)","help":"Timeout in milliseconds for connecting to a remote CDP endpoint before failing the browser attach attempt. Increase for high-latency tunnels, or lower for faster failure detection.","hasChildren":false} {"recordType":"path","path":"browser.snapshotDefaults","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Browser Snapshot Defaults","help":"Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.","hasChildren":true} diff --git a/docs/cli/browser.md b/docs/cli/browser.md index f9ddc151717..42af08f84f3 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -1,9 +1,9 @@ --- -summary: "CLI reference for `openclaw browser` (profiles, tabs, actions, extension relay)" +summary: "CLI reference for `openclaw browser` (profiles, tabs, actions, Chrome MCP, and CDP)" read_when: - You use `openclaw browser` and want examples for common tasks - You want to control a browser running on another machine via a node host - - You want to use the Chrome extension relay (attach/detach via toolbar button) + - You want to attach to your local signed-in Chrome via Chrome MCP title: "browser" --- @@ -14,7 +14,6 @@ Manage OpenClaw’s browser control server and run browser actions (tabs, snapsh Related: - Browser tool + API: [Browser tool](/tools/browser) -- Chrome extension relay: [Chrome extension](/tools/chrome-extension) ## Common flags @@ -37,13 +36,14 @@ openclaw browser --browser-profile openclaw snapshot Profiles are named browser routing configs. In practice: -- `openclaw`: launches/attaches to a dedicated OpenClaw-managed Chrome instance (isolated user data dir). +- `openclaw`: launches or attaches to a dedicated OpenClaw-managed Chrome instance (isolated user data dir). - `user`: controls your existing signed-in Chrome session via Chrome DevTools MCP. -- `chrome-relay`: controls your existing Chrome tab(s) via the Chrome extension relay. +- custom CDP profiles: point at a local or remote CDP endpoint. ```bash openclaw browser profiles openclaw browser create-profile --name work --color "#FF5A36" +openclaw browser create-profile --name chrome-live --driver existing-session openclaw browser delete-profile --name work ``` @@ -84,20 +84,17 @@ openclaw browser click openclaw browser type "hello" ``` -## Chrome extension relay (attach via toolbar button) +## Existing Chrome via MCP -This mode lets the agent control an existing Chrome tab that you attach manually (it does not auto-attach). - -Install the unpacked extension to a stable path: +Use the built-in `user` profile, or create your own `existing-session` profile: ```bash -openclaw browser extension install -openclaw browser extension path +openclaw browser --browser-profile user tabs +openclaw browser create-profile --name chrome-live --driver existing-session +openclaw browser --browser-profile chrome-live tabs ``` -Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → select the printed folder. - -Full guide: [Chrome extension](/tools/chrome-extension) +This path is host-only. For Docker, headless servers, Browserless, or other remote setups, use a CDP profile instead. ## Remote browser control (node host proxy) diff --git a/docs/cli/docs.md b/docs/cli/docs.md index 6b79aabe6f1..744c50e1432 100644 --- a/docs/cli/docs.md +++ b/docs/cli/docs.md @@ -10,6 +10,6 @@ title: "docs" Search the live docs index. ```bash -openclaw docs browser extension +openclaw docs browser existing-session openclaw docs sandbox allowHostControl ``` diff --git a/docs/docs.json b/docs/docs.json index 229699ec37e..80409046397 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1018,7 +1018,6 @@ "pages": [ "tools/browser", "tools/browser-login", - "tools/chrome-extension", "tools/browser-linux-troubleshooting" ] }, @@ -1613,7 +1612,6 @@ "pages": [ "zh-CN/tools/browser", "zh-CN/tools/browser-login", - "zh-CN/tools/chrome-extension", "zh-CN/tools/browser-linux-troubleshooting" ] }, diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 0653fd3834f..a46f342a360 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2442,13 +2442,13 @@ See [Plugins](/tools/plugin). profiles: { openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, color: "#FF4500", // headless: false, // noSandbox: false, // extraArgs: [], - // relayBindHost: "0.0.0.0", // only when the extension relay must be reachable across namespaces (for example WSL2) // executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", // attachOnly: false, }, @@ -2462,11 +2462,11 @@ See [Plugins](/tools/plugin). - `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias. - In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions. - Remote profiles are attach-only (start/stop/reset disabled). +- `existing-session` profiles are host-only and use Chrome MCP instead of CDP. - Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary. - Control service: loopback only (port derived from `gateway.port`, default `18791`). - `extraArgs` appends extra launch flags to local Chromium startup (for example `--disable-gpu`, window sizing, or debug flags). -- `relayBindHost` changes where the Chrome extension relay listens. Leave unset for loopback-only access; set an explicit non-loopback bind address such as `0.0.0.0` only when the relay must cross a namespace boundary (for example WSL2) and the host network is already trusted. --- diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 95027906750..6c0711c7aea 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -63,6 +63,7 @@ cat ~/.openclaw/openclaw.json - Health check + restart prompt. - Skills status summary (eligible/missing/blocked). - Config normalization for legacy values. +- Browser migration checks for legacy Chrome extension configs and Chrome MCP readiness. - OpenCode provider override warnings (`models.providers.opencode` / `models.providers.opencode-go`). - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). - Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs). @@ -128,6 +129,8 @@ Current migrations: - `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks` → `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks` - `browser.ssrfPolicy.allowPrivateNetwork` → `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` +- `browser.profiles.*.driver: "extension"` → `"existing-session"` +- remove `browser.relayBindHost` (legacy extension relay setting) Doctor warnings also include account-default guidance for multi-account channels: @@ -141,6 +144,33 @@ manually, it overrides the built-in OpenCode catalog from `@mariozechner/pi-ai`. That can force models onto the wrong API or zero out costs. Doctor warns so you can remove the override and restore per-model API routing + costs. +### 2c) Browser migration and Chrome MCP readiness + +If your browser config still points at the removed Chrome extension path, doctor +normalizes it to the current host-local Chrome MCP attach model: + +- `browser.profiles.*.driver: "extension"` becomes `"existing-session"` +- `browser.relayBindHost` is removed + +Doctor also audits the host-local Chrome MCP path when you use `defaultProfile: +"user"` or a configured `existing-session` profile: + +- checks whether Google Chrome is installed on the same host +- checks the detected Chrome version and warns when it is below Chrome 144 +- reminds you to enable remote debugging in Chrome at + `chrome://inspect/#remote-debugging` + +Doctor cannot enable the Chrome-side setting for you. Host-local Chrome MCP +still requires: + +- Google Chrome 144+ on the gateway/node host +- Chrome running locally +- remote debugging enabled in Chrome +- approving the first attach consent prompt in Chrome + +This check does **not** apply to Docker, sandbox, remote-browser, or other +headless flows. Those continue to use raw CDP. + ### 3) Legacy state migrations (disk layout) Doctor can migrate older on-disk layouts into the current structure: diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 595e50f2628..68be08fbed5 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -990,10 +990,9 @@ access those accounts and data. Treat browser profiles as **sensitive state**: - Treat browser downloads as untrusted input; prefer an isolated downloads directory. - Disable browser sync/password managers in the agent profile if possible (reduces blast radius). - For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach. -- Keep the Gateway and node hosts tailnet-only; avoid exposing relay/control ports to LAN or public Internet. -- The Chrome extension relay’s CDP endpoint is auth-gated; only OpenClaw clients can connect. +- Keep the Gateway and node hosts tailnet-only; avoid exposing browser control ports to LAN or public Internet. - Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`). -- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach. +- Chrome MCP existing-session mode is **not** “safer”; it can act as you in whatever that host Chrome profile can reach. ### Browser SSRF policy (trusted-network default) diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 41c697a67f1..aa75b9cf2b5 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -289,19 +289,18 @@ Look for: - Valid browser executable path. - CDP profile reachability. -- Extension relay tab attachment (if an extension relay profile is configured). +- Local Chrome availability for `existing-session` / `user` profiles. Common signatures: - `Failed to start Chrome CDP on port` → browser process failed to launch. - `browser.executablePath not found` → configured path is invalid. -- `Chrome extension relay is running, but no tab is connected` → extension relay not attached. +- `No Chrome tabs found for profile="user"` → the Chrome MCP attach profile has no open local Chrome tabs. - `Browser attachOnly is enabled ... not reachable` → attach-only profile has no reachable target. Related: - [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting) -- [/tools/chrome-extension](/tools/chrome-extension) - [/tools/browser](/tools/browser) ## If you upgraded and something suddenly broke diff --git a/docs/help/faq.md b/docs/help/faq.md index 8fdf39ab5c1..b32b1aac8c5 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -80,7 +80,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Can OpenClaw run tasks on a schedule or continuously in the background?](#can-openclaw-run-tasks-on-a-schedule-or-continuously-in-the-background) - [Can I run Apple macOS-only skills from Linux?](#can-i-run-apple-macos-only-skills-from-linux) - [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration) - - [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover) + - [How do I use my existing signed-in Chrome with OpenClaw?](#how-do-i-use-my-existing-signed-in-chrome-with-openclaw) - [Sandboxing and memory](#sandboxing-and-memory) - [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc) - [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox) @@ -1214,22 +1214,23 @@ clawhub update --all ClawHub installs into `./skills` under your current directory (or falls back to your configured OpenClaw workspace); OpenClaw treats that as `/skills` on the next session. For shared skills across agents, place them in `~/.openclaw/skills//SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawHub](/tools/clawhub). -### How do I install the Chrome extension for browser takeover +### How do I use my existing signed-in Chrome with OpenClaw -Use the built-in installer, then load the unpacked extension in Chrome: +Use the built-in `user` browser profile, which attaches through Chrome DevTools MCP: ```bash -openclaw browser extension install -openclaw browser extension path +openclaw browser --browser-profile user tabs +openclaw browser --browser-profile user snapshot ``` -Then Chrome → `chrome://extensions` → enable "Developer mode" → "Load unpacked" → pick that folder. +If you want a custom name, create an explicit MCP profile: -Full guide (including remote Gateway + security notes): [Chrome extension](/tools/chrome-extension) +```bash +openclaw browser create-profile --name chrome-live --driver existing-session +openclaw browser --browser-profile chrome-live tabs +``` -If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need anything extra. -If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions. -You still need to click the extension button on the tab you want to control (it doesn't auto-attach). +This path is host-local. If the Gateway runs elsewhere, either run a node host on the browser machine or use remote CDP instead. ## Sandboxing and memory @@ -1665,13 +1666,12 @@ setup is an always-on host plus your laptop as a node. - **No inbound SSH required.** Nodes connect out to the Gateway WebSocket and use device pairing. - **Safer execution controls.** `system.run` is gated by node allowlists/approvals on that laptop. - **More device tools.** Nodes expose `canvas`, `camera`, and `screen` in addition to `system.run`. -- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally and relay control - with the Chrome extension + a node host on the laptop. +- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally through a node host on the laptop, or attach to local Chrome on the host via Chrome MCP. SSH is fine for ad-hoc shell access, but nodes are simpler for ongoing agent workflows and device automation. -Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes), [Chrome extension](/tools/chrome-extension). +Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes), [Browser](/tools/browser). ### Should I install on a second laptop or just add a node @@ -2039,18 +2039,18 @@ Yes. Use **Multi-Agent Routing** to run multiple isolated agents and route inbou channel/account/peer. Slack is supported as a channel and can be bound to specific agents. Browser access is powerful but not "do anything a human can" - anti-bot, CAPTCHAs, and MFA can -still block automation. For the most reliable browser control, use the Chrome extension relay -on the machine that runs the browser (and keep the Gateway anywhere). +still block automation. For the most reliable browser control, use local Chrome MCP on the host, +or use CDP on the machine that actually runs the browser. Best-practice setup: - Always-on Gateway host (VPS/Mac mini). - One agent per role (bindings). - Slack channel(s) bound to those agents. -- Local browser via extension relay (or a node) when needed. +- Local browser via Chrome MCP or a node when needed. Docs: [Multi-Agent Routing](/concepts/multi-agent), [Slack](/channels/slack), -[Browser](/tools/browser), [Chrome extension](/tools/chrome-extension), [Nodes](/nodes). +[Browser](/tools/browser), [Nodes](/nodes). ## Models: defaults, selection, aliases, switching diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index a3988c4ea58..1660100ba8c 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -278,13 +278,13 @@ flowchart TD Good output looks like: - Browser status shows `running: true` and a chosen browser/profile. - - `openclaw` profile starts or `chrome` relay has an attached tab. + - `openclaw` starts, or `user` can see local Chrome tabs. Common log signatures: - `Failed to start Chrome CDP on port` → local browser launch failed. - `browser.executablePath not found` → configured binary path is wrong. - - `Chrome extension relay is running, but no tab is connected` → extension not attached. + - `No Chrome tabs found for profile="user"` → the Chrome MCP attach profile has no open local Chrome tabs. - `Browser attachOnly is enabled ... not reachable` → attach-only profile has no live CDP target. Deep pages: @@ -292,7 +292,6 @@ flowchart TD - [/gateway/troubleshooting#browser-tool-fails](/gateway/troubleshooting#browser-tool-fails) - [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting) - [/tools/browser-wsl2-windows-remote-cdp-troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting) - - [/tools/chrome-extension](/tools/chrome-extension) diff --git a/docs/install/docker.md b/docs/install/docker.md index a3827075202..a9f6b578bd0 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -713,6 +713,7 @@ an optional noVNC observer (headful via Xvfb). Notes: +- Docker and other headless/container browser flows stay on raw CDP. Chrome MCP `existing-session` is for host-local Chrome, not container takeover. - Headful (Xvfb) reduces bot blocking vs headless. - Headless can still be used by setting `agents.defaults.sandbox.browser.headless=true`. - No full desktop environment (GNOME) is needed; Xvfb provides the display. diff --git a/docs/tools/browser-linux-troubleshooting.md b/docs/tools/browser-linux-troubleshooting.md index 6f9940c1c67..2a5196c3739 100644 --- a/docs/tools/browser-linux-troubleshooting.md +++ b/docs/tools/browser-linux-troubleshooting.md @@ -121,19 +121,18 @@ curl -s http://127.0.0.1:18791/tabs | `browser.attachOnly` | Don't launch browser, only attach to existing | `false` | | `browser.cdpPort` | Chrome DevTools Protocol port | `18800` | -### Problem: "Chrome extension relay is running, but no tab is connected" +### Problem: "No Chrome tabs found for profile=\"user\"" -You're using an extension relay profile. It expects the OpenClaw -browser extension to be attached to a live tab. +You're using an `existing-session` / Chrome MCP profile. OpenClaw can see local Chrome, +but there are no open tabs available to attach to. Fix options: 1. **Use the managed browser:** `openclaw browser start --browser-profile openclaw` (or set `browser.defaultProfile: "openclaw"`). -2. **Use the extension relay:** install the extension, open a tab, and click the - OpenClaw extension icon to attach it. +2. **Use Chrome MCP:** make sure local Chrome is running with at least one open tab, then retry with `--browser-profile user`. Notes: -- The `chrome-relay` profile uses your **system default Chromium browser** when possible. +- `user` is host-only. For Linux servers, containers, or remote hosts, prefer CDP profiles. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; only set those for remote CDP. diff --git a/docs/tools/browser-login.md b/docs/tools/browser-login.md index d570b3b2e87..52135a80673 100644 --- a/docs/tools/browser-login.md +++ b/docs/tools/browser-login.md @@ -24,7 +24,6 @@ For agent browser tool calls: - Default choice: the agent should use its isolated `openclaw` browser. - Use `profile="user"` only when existing logged-in sessions matter and the user is at the computer to click/approve any attach prompt. -- Use `profile="chrome-relay"` only for the Chrome extension / toolbar-button attach flow. - If you have multiple user-browser profiles, specify the profile explicitly instead of guessing. Two easy ways to access it: diff --git a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md index 2e7844860aa..6824cee6788 100644 --- a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md +++ b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md @@ -1,9 +1,9 @@ --- -summary: "Troubleshoot WSL2 Gateway + Windows Chrome remote CDP and extension-relay setups in layers" +summary: "Troubleshoot WSL2 Gateway + Windows Chrome remote CDP in layers" read_when: - Running OpenClaw Gateway in WSL2 while Chrome lives on Windows - Seeing overlapping browser/control-ui errors across WSL2 and Windows - - Deciding between raw remote CDP and the Chrome extension relay in split-host setups + - Deciding between host-local Chrome MCP and raw remote CDP in split-host setups title: "WSL2 + Windows + remote Chrome CDP troubleshooting" --- @@ -21,27 +21,27 @@ It also covers the layered failure pattern from [issue #39369](https://github.co You have two valid patterns: -### Option 1: Raw remote CDP +### Option 1: Raw remote CDP from WSL2 to Windows Use a remote browser profile that points from WSL2 to a Windows Chrome CDP endpoint. Choose this when: -- you only need browser control -- you are comfortable exposing Chrome remote debugging to WSL2 -- you do not need the Chrome extension relay +- the Gateway stays inside WSL2 +- Chrome runs on Windows +- you need browser control to cross the WSL2/Windows boundary -### Option 2: Chrome extension relay +### Option 2: Host-local Chrome MCP -Use the built-in `chrome-relay` profile plus the OpenClaw Chrome extension. +Use `existing-session` / `user` only when the Gateway itself runs on the same host as Chrome. Choose this when: -- you want to attach to an existing Windows Chrome tab with the toolbar button -- you want extension-based control instead of raw `--remote-debugging-port` -- the relay itself must be reachable across the WSL2/Windows boundary +- OpenClaw and Chrome are on the same machine +- you want the local signed-in browser state +- you do not need cross-host browser transport -If you use the extension relay across namespaces, `browser.relayBindHost` is the important setting introduced in [Browser](/tools/browser) and [Chrome extension](/tools/chrome-extension). +For WSL2 Gateway + Windows Chrome, prefer raw remote CDP. Chrome MCP is host-local, not a WSL2-to-Windows bridge. ## Working architecture @@ -62,7 +62,6 @@ Several failures can overlap: - `gateway.controlUi.allowedOrigins` does not match the page origin - token or pairing is missing - the browser profile points at the wrong address -- the extension relay is still loopback-only when you actually need cross-namespace access Because of that, fixing one layer can still leave a different error visible. @@ -145,31 +144,7 @@ Notes: - keep `attachOnly: true` for externally managed browsers - test the same URL with `curl` before expecting OpenClaw to succeed -### Layer 4: If you use the Chrome extension relay instead - -If the browser machine and the Gateway are separated by a namespace boundary, the relay may need a non-loopback bind address. - -Example: - -```json5 -{ - browser: { - enabled: true, - defaultProfile: "chrome-relay", - relayBindHost: "0.0.0.0", - }, -} -``` - -Use this only when needed: - -- default behavior is safer because the relay stays loopback-only -- `0.0.0.0` expands exposure surface -- keep Gateway auth, node pairing, and the surrounding network private - -If you do not need the extension relay, prefer the raw remote CDP profile above. - -### Layer 5: Verify the Control UI layer separately +### Layer 4: Verify the Control UI layer separately Open the UI from Windows: @@ -185,7 +160,7 @@ Helpful page: - [Control UI](/web/control-ui) -### Layer 6: Verify end-to-end browser control +### Layer 5: Verify end-to-end browser control From WSL2: @@ -194,12 +169,6 @@ openclaw browser open https://example.com --browser-profile remote openclaw browser tabs --browser-profile remote ``` -For the extension relay: - -```bash -openclaw browser tabs --browser-profile chrome-relay -``` - Good result: - the tab opens in Windows Chrome @@ -220,8 +189,8 @@ Treat each message as a layer-specific clue: - WSL2 cannot reach the configured `cdpUrl` - `gateway timeout after 1500ms` - often still CDP reachability or a slow/unreachable remote endpoint -- `Chrome extension relay is running, but no tab is connected` - - extension relay profile selected, but no attached tab exists yet +- `No Chrome tabs found for profile="user"` + - local Chrome MCP profile selected where no host-local tabs are available ## Fast triage checklist @@ -229,11 +198,11 @@ Treat each message as a layer-specific clue: 2. WSL2: does `curl http://WINDOWS_HOST_OR_IP:9222/json/version` work? 3. OpenClaw config: does `browser.profiles..cdpUrl` use that exact WSL2-reachable address? 4. Control UI: are you opening `http://127.0.0.1:18789/` instead of a LAN IP? -5. Extension relay only: do you actually need `browser.relayBindHost`, and if so is it set explicitly? +5. Are you trying to use `existing-session` across WSL2 and Windows instead of raw remote CDP? ## Practical takeaway -The setup is usually viable. The hard part is that browser transport, Control UI origin security, token/pairing, and extension-relay topology can each fail independently while looking similar from the user side. +The setup is usually viable. The hard part is that browser transport, Control UI origin security, and token/pairing can each fail independently while looking similar from the user side. When in doubt: diff --git a/docs/tools/browser.md b/docs/tools/browser.md index c760c23998c..19ee23a25ca 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -18,8 +18,7 @@ Beginner view: - Think of it as a **separate, agent-only browser**. - The `openclaw` profile does **not** touch your personal browser profile. - The agent can **open tabs, read pages, click, and type** in a safe lane. -- The built-in `user` profile attaches to your real signed-in Chrome session; - `chrome-relay` is the explicit extension-relay profile. +- The built-in `user` profile attaches to your real signed-in Chrome session via Chrome MCP. ## What you get @@ -43,21 +42,17 @@ openclaw browser --browser-profile openclaw snapshot If you get “Browser disabled”, enable it in config (see below) and restart the Gateway. -## Profiles: `openclaw` vs `user` vs `chrome-relay` +## Profiles: `openclaw` vs `user` - `openclaw`: managed, isolated browser (no extension required). - `user`: built-in Chrome MCP attach profile for your **real signed-in Chrome** session. -- `chrome-relay`: extension relay to your **system browser** (requires the - OpenClaw extension to be attached to a tab). For agent browser tool calls: - Default: use the isolated `openclaw` browser. - Prefer `profile="user"` when existing logged-in sessions matter and the user is at the computer to click/approve any attach prompt. -- Use `profile="chrome-relay"` only when the user explicitly wants the Chrome - extension / toolbar-button attach flow. - `profile` is the explicit override when you want a specific browser mode. Set `browser.defaultProfile: "openclaw"` if you want managed mode by default. @@ -93,11 +88,6 @@ Browser settings live in `~/.openclaw/openclaw.json`. attachOnly: true, color: "#00AA00", }, - "chrome-relay": { - driver: "extension", - cdpUrl: "http://127.0.0.1:18792", - color: "#00AA00", - }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, }, @@ -107,10 +97,10 @@ Browser settings live in `~/.openclaw/openclaw.json`. Notes: - The browser control service binds to loopback on a port derived from `gateway.port` - (default: `18791`, which is gateway + 2). The relay uses the next port (`18792`). + (default: `18791`, which is gateway + 2). - If you override the Gateway port (`gateway.port` or `OPENCLAW_GATEWAY_PORT`), the derived browser ports shift to stay in the same “family”. -- `cdpUrl` defaults to the relay port when unset. +- `cdpUrl` defaults to the managed local CDP port when unset. - `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks. - `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks. - Browser navigation/open-tab is SSRF-guarded before navigation and best-effort re-checked on final `http(s)` URL after navigation. @@ -119,7 +109,7 @@ Notes: - `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility. - `attachOnly: true` means “never launch a local browser; only attach if it is already running.” - `color` + per-profile `color` tint the browser UI so you can see which profile is active. -- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "user"` to opt into the signed-in user browser, or `defaultProfile: "chrome-relay"` for the extension relay. +- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "user"` to opt into the signed-in user browser. - Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP. - `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do @@ -287,77 +277,18 @@ OpenClaw supports multiple named profiles (routing configs). Profiles can be: - **openclaw-managed**: a dedicated Chromium-based browser instance with its own user data directory + CDP port - **remote**: an explicit CDP URL (Chromium-based browser running elsewhere) -- **extension relay**: your existing Chrome tab(s) via the local relay + Chrome extension - **existing session**: your existing Chrome profile via Chrome DevTools MCP auto-connect Defaults: - The `openclaw` profile is auto-created if missing. -- The `chrome-relay` profile is built-in for the Chrome extension relay (points at `http://127.0.0.1:18792` by default). -- Existing-session profiles are opt-in; create them with `--driver existing-session`. +- The `user` profile is built-in for Chrome MCP existing-session attach. +- Existing-session profiles are opt-in beyond `user`; create them with `--driver existing-session`. - Local CDP ports allocate from **18800–18899** by default. - Deleting a profile moves its local data directory to Trash. All control endpoints accept `?profile=`; the CLI uses `--browser-profile`. -## Chrome extension relay (use your existing Chrome) - -OpenClaw can also drive **your existing Chrome tabs** (no separate “openclaw” Chrome instance) via a local CDP relay + a Chrome extension. - -Full guide: [Chrome extension](/tools/chrome-extension) - -Flow: - -- The Gateway runs locally (same machine) or a node host runs on the browser machine. -- A local **relay server** listens at a loopback `cdpUrl` (default: `http://127.0.0.1:18792`). -- You click the **OpenClaw Browser Relay** extension icon on a tab to attach (it does not auto-attach). -- The agent controls that tab via the normal `browser` tool, by selecting the right profile. - -If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions. - -### Sandboxed sessions - -If the agent session is sandboxed, the `browser` tool may default to `target="sandbox"` (sandbox browser). -Chrome extension relay takeover requires host browser control, so either: - -- run the session unsandboxed, or -- set `agents.defaults.sandbox.browser.allowHostControl: true` and use `target="host"` when calling the tool. - -### Setup - -1. Load the extension (dev/unpacked): - -```bash -openclaw browser extension install -``` - -- Chrome → `chrome://extensions` → enable “Developer mode” -- “Load unpacked” → select the directory printed by `openclaw browser extension path` -- Pin the extension, then click it on the tab you want to control (badge shows `ON`). - -2. Use it: - -- CLI: `openclaw browser --browser-profile chrome-relay tabs` -- Agent tool: `browser` with `profile="chrome-relay"` - -Optional: if you want a different name or relay port, create your own profile: - -```bash -openclaw browser create-profile \ - --name my-chrome \ - --driver extension \ - --cdp-url http://127.0.0.1:18792 \ - --color "#00AA00" -``` - -Notes: - -- This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions). -- Detach by clicking the extension icon again. -- Agent use: prefer `profile="user"` for logged-in sites. Use `profile="chrome-relay"` - only when you specifically want the extension flow. The user must be present - to click the extension and attach the tab. - ## Chrome existing-session via MCP OpenClaw can also attach to a running Chrome profile through the official @@ -404,13 +335,14 @@ What to check if attach does not work: - Chrome is version `144+` - remote debugging is enabled at `chrome://inspect/#remote-debugging` - Chrome showed and you accepted the attach consent prompt +- `openclaw doctor` migrates old extension-based browser config and checks that + Chrome is installed locally with a compatible version, but it cannot enable + Chrome-side remote debugging for you Agent use: - Use `profile="user"` when you need the user’s logged-in browser state. - If you use a custom existing-session profile, pass that explicit profile name. -- Prefer `profile="user"` over `profile="chrome-relay"` unless the user - explicitly wants the extension / attach-tab flow. - Only choose this mode when the user is at the computer to approve the attach prompt. - the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect` @@ -427,21 +359,10 @@ Notes: captures from snapshots, but not CSS `--element` selectors. - Existing-session `wait --url` supports exact, substring, and glob patterns like other browser drivers. `wait --load networkidle` is not supported yet. -- Some features still require the extension relay or managed browser path, such - as PDF export and download interception. -- Leave the relay loopback-only by default. If the relay must be reachable from a different network namespace (for example Gateway in WSL2, Chrome on Windows), set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0` while keeping the surrounding network private and authenticated. - -WSL2 / cross-namespace example: - -```json5 -{ - browser: { - enabled: true, - relayBindHost: "0.0.0.0", - defaultProfile: "chrome-relay", - }, -} -``` +- Some features still require the managed browser path, such as PDF export and + download interception. +- Existing-session is host-local. If Chrome lives on a different machine or a + different network namespace, use remote CDP or a node host instead. ## Isolation guarantees @@ -496,7 +417,6 @@ If gateway auth is configured, browser HTTP routes require auth too: Some features (navigate/act/AI snapshot/role snapshot, element screenshots, PDF) require Playwright. If Playwright isn’t installed, those endpoints return a clear 501 error. ARIA snapshots and basic screenshots still work for openclaw-managed Chrome. -For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright. If you see `Playwright is not available in this gateway build`, install the full Playwright package (not `playwright-core`) and restart the gateway, or reinstall diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md deleted file mode 100644 index 831897b9bde..00000000000 --- a/docs/tools/chrome-extension.md +++ /dev/null @@ -1,203 +0,0 @@ ---- -summary: "Chrome extension: let OpenClaw drive your existing Chrome tab" -read_when: - - You want the agent to drive an existing Chrome tab (toolbar button) - - You need remote Gateway + local browser automation via Tailscale - - You want to understand the security implications of browser takeover -title: "Chrome Extension" ---- - -# Chrome extension (browser relay) - -The OpenClaw Chrome extension lets the agent control your **existing Chrome tabs** (your normal Chrome window) instead of launching a separate openclaw-managed Chrome profile. - -Attach/detach happens via a **single Chrome toolbar button**. - -If you want Chrome’s official DevTools MCP attach flow instead of the OpenClaw -extension relay, use an `existing-session` browser profile instead. See -[Browser](/tools/browser#chrome-existing-session-via-mcp). For Chrome’s own -setup docs, see [Chrome for Developers: Use Chrome DevTools MCP with your -browser session](https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session) -and the [Chrome DevTools MCP README](https://github.com/ChromeDevTools/chrome-devtools-mcp). - -## What it is (concept) - -There are three parts: - -- **Browser control service** (Gateway or node): the API the agent/tool calls (via the Gateway) -- **Local relay server** (loopback CDP): bridges between the control server and the extension (`http://127.0.0.1:18792` by default) -- **Chrome MV3 extension**: attaches to the active tab using `chrome.debugger` and pipes CDP messages to the relay - -OpenClaw then controls the attached tab through the normal `browser` tool surface (selecting the right profile). - -## Install / load (unpacked) - -1. Install the extension to a stable local path: - -```bash -openclaw browser extension install -``` - -2. Print the installed extension directory path: - -```bash -openclaw browser extension path -``` - -3. Chrome → `chrome://extensions` - -- Enable “Developer mode” -- “Load unpacked” → select the directory printed above - -4. Pin the extension. - -## Updates (no build step) - -The extension ships inside the OpenClaw release (npm package) as static files. There is no separate “build” step. - -After upgrading OpenClaw: - -- Re-run `openclaw browser extension install` to refresh the installed files under your OpenClaw state directory. -- Chrome → `chrome://extensions` → click “Reload” on the extension. - -## Use it (set gateway token once) - -To use the extension relay, create a browser profile for it: - -Before first attach, open extension Options and set: - -- `Port` (default `18792`) -- `Gateway token` (must match `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN`) - -Then create a profile: - -```bash -openclaw browser create-profile \ - --name my-chrome \ - --driver extension \ - --cdp-url http://127.0.0.1:18792 \ - --color "#00AA00" -``` - -Use it: - -- CLI: `openclaw browser --browser-profile my-chrome tabs` -- Agent tool: `browser` with `profile="my-chrome"` - -### Custom Gateway ports - -If you're using a custom gateway port, the extension relay port is automatically derived: - -**Extension Relay Port = Gateway Port + 3** - -Example: if `gateway.port: 19001`, then: - -- Extension relay port: `19004` (gateway + 3) - -Configure the extension to use the derived relay port in the extension Options page. - -## Attach / detach (toolbar button) - -- Open the tab you want OpenClaw to control. -- Click the extension icon. - - Badge shows `ON` when attached. -- Click again to detach. - -## Which tab does it control? - -- It does **not** automatically control “whatever tab you’re looking at”. -- It controls **only the tab(s) you explicitly attached** by clicking the toolbar button. -- To switch: open the other tab and click the extension icon there. - -## Badge + common errors - -- `ON`: attached; OpenClaw can drive that tab. -- `…`: connecting to the local relay. -- `!`: relay not reachable/authenticated (most common: relay server not running, or gateway token missing/wrong). - -If you see `!`: - -- Make sure the Gateway is running locally (default setup), or run a node host on this machine if the Gateway runs elsewhere. -- Open the extension Options page; it validates relay reachability + gateway-token auth. - -## Remote Gateway (use a node host) - -### Local Gateway (same machine as Chrome) — usually **no extra steps** - -If the Gateway runs on the same machine as Chrome, it starts the browser control service on loopback -and auto-starts the relay server. The extension talks to the local relay; the CLI/tool calls go to the Gateway. - -### Remote Gateway (Gateway runs elsewhere) — **run a node host** - -If your Gateway runs on another machine, start a node host on the machine that runs Chrome. -The Gateway will proxy browser actions to that node; the extension + relay stay local to the browser machine. - -If multiple nodes are connected, pin one with `gateway.nodes.browser.node` or set `gateway.nodes.browser.mode`. - -## Sandboxing (tool containers) - -If your agent session is sandboxed (`agents.defaults.sandbox.mode != "off"`), the `browser` tool can be restricted: - -- By default, sandboxed sessions often target the **sandbox browser** (`target="sandbox"`), not your host Chrome. -- Chrome extension relay takeover requires controlling the **host** browser control server. - -Options: - -- Easiest: use the extension from a **non-sandboxed** session/agent. -- Or allow host browser control for sandboxed sessions: - -```json5 -{ - agents: { - defaults: { - sandbox: { - browser: { - allowHostControl: true, - }, - }, - }, - }, -} -``` - -Then ensure the tool isn’t denied by tool policy, and (if needed) call `browser` with `target="host"`. - -Debugging: `openclaw sandbox explain` - -## Remote access tips - -- Keep the Gateway and node host on the same tailnet; avoid exposing relay ports to LAN or public Internet. -- Pair nodes intentionally; disable browser proxy routing if you don’t want remote control (`gateway.nodes.browser.mode="off"`). -- Leave the relay on loopback unless you have a real cross-namespace need. For WSL2 or similar split-host setups, set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0`, then keep access constrained with Gateway auth, node pairing, and a private network. - -## How “extension path” works - -`openclaw browser extension path` prints the **installed** on-disk directory containing the extension files. - -The CLI intentionally does **not** print a `node_modules` path. Always run `openclaw browser extension install` first to copy the extension to a stable location under your OpenClaw state directory. - -If you move or delete that install directory, Chrome will mark the extension as broken until you reload it from a valid path. - -## Security implications (read this) - -This is powerful and risky. Treat it like giving the model “hands on your browser”. - -- The extension uses Chrome’s debugger API (`chrome.debugger`). When attached, the model can: - - click/type/navigate in that tab - - read page content - - access whatever the tab’s logged-in session can access -- **This is not isolated** like the dedicated openclaw-managed profile. - - If you attach to your daily-driver profile/tab, you’re granting access to that account state. - -Recommendations: - -- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage. -- Keep the Gateway and any node hosts tailnet-only; rely on Gateway auth + node pairing. -- Avoid exposing relay ports over LAN (`0.0.0.0`) and avoid Funnel (public). -- The relay blocks non-extension origins and requires gateway-token auth for both `/cdp` and `/extension`. - -Related: - -- Browser tool overview: [Browser](/tools/browser) -- Security audit: [Security](/gateway/security) -- Tailscale setup: [Tailscale](/gateway/tailscale) diff --git a/docs/tools/index.md b/docs/tools/index.md index dbca6cd26bf..deb42b0d76a 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -318,8 +318,7 @@ Common parameters: - All actions accept optional `profile` parameter for multi-instance support. - Omit `profile` for the safe default: isolated OpenClaw-managed browser (`openclaw`). - Use `profile="user"` for the real local host browser when existing logins/cookies matter and the user is present to click/approve any attach prompt. -- Use `profile="chrome-relay"` only for the Chrome extension / toolbar-button attach flow. -- `profile="user"` and `profile="chrome-relay"` are host-only; do not combine them with sandbox/node targets. +- `profile="user"` is host-only; do not combine it with sandbox/node targets. - When `profile` is omitted, uses `browser.defaultProfile` (defaults to `openclaw`). - Profile names: lowercase alphanumeric + hyphens only (max 64 chars). - Port range: 18800-18899 (~100 profiles max). diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index c818344f886..76a0be3b466 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -60,7 +60,6 @@ const unitIsolatedFilesRaw = [ // Skills discovery/snapshot suites are filesystem-heavy and high-variance in vmForks lanes. "src/agents/skills.test.ts", "src/agents/skills.buildworkspaceskillsnapshot.test.ts", - "src/browser/extension-relay.test.ts", "extensions/acpx/src/runtime.test.ts", // Shell-heavy script harness can contend under vmForks startup bursts. "test/scripts/ios-team-id.test.ts", diff --git a/src/agents/tools/browser-tool.actions.ts b/src/agents/tools/browser-tool.actions.ts index 0d0f5e26abb..dc78557eab2 100644 --- a/src/agents/tools/browser-tool.actions.ts +++ b/src/agents/tools/browser-tool.actions.ts @@ -1,7 +1,9 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { browserAct, browserConsoleMessages } from "../../browser/client-actions.js"; import { browserSnapshot, browserTabs } from "../../browser/client.js"; +import { resolveBrowserConfig, resolveProfile } from "../../browser/config.js"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js"; +import { getBrowserProfileCapabilities } from "../../browser/profile-capabilities.js"; import { loadConfig } from "../../config/config.js"; import { wrapExternalContent } from "../../security/external-content.js"; import { imageResultFromFile, jsonResult } from "./common.js"; @@ -74,7 +76,17 @@ function formatConsoleToolResult(result: { } function isChromeStaleTargetError(profile: string | undefined, err: unknown): boolean { - if (profile !== "chrome-relay" && profile !== "chrome" && profile !== "user") { + if (!profile) { + return false; + } + if (profile === "user") { + const msg = String(err); + return msg.includes("404:") && msg.includes("tab not found"); + } + const cfg = loadConfig(); + const resolved = resolveBrowserConfig(cfg.browser, cfg); + const browserProfile = resolveProfile(resolved, profile); + if (!browserProfile || !getBrowserProfileCapabilities(browserProfile).usesChromeMcp) { return false; } const msg = String(err); @@ -334,12 +346,8 @@ export async function executeActAction(params: { } } if (!tabs.length) { - // Extension relay profiles need the toolbar icon click; Chrome MCP just needs Chrome running. - const isRelayProfile = profile === "chrome-relay" || profile === "chrome"; throw new Error( - isRelayProfile - ? "No Chrome tabs are attached via the OpenClaw Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry." - : `No Chrome tabs found for profile="${profile}". Make sure Chrome (v146+) is running and has open tabs, then retry.`, + `No Chrome tabs found for profile="${profile}". Make sure Chrome (v144+) is running and has open tabs, then retry.`, { cause: err }, ); } diff --git a/src/agents/tools/browser-tool.test.ts b/src/agents/tools/browser-tool.test.ts index b938d177624..622fa7ed8b3 100644 --- a/src/agents/tools/browser-tool.test.ts +++ b/src/agents/tools/browser-tool.test.ts @@ -64,12 +64,7 @@ const browserConfigMocks = vi.hoisted(() => ({ if (!profile) { return null; } - const driver = - profile.driver === "extension" - ? "extension" - : profile.driver === "existing-session" - ? "existing-session" - : "openclaw"; + const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw"; if (driver === "existing-session") { return { name, @@ -287,29 +282,6 @@ describe("browser tool snapshot maxChars", () => { expect(opts?.mode).toBeUndefined(); }); - it("defaults to host when using an explicit extension relay profile (even in sandboxed sessions)", async () => { - setResolvedBrowserProfiles({ - relay: { - driver: "extension", - cdpUrl: "http://127.0.0.1:18792", - color: "#0066CC", - }, - }); - const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" }); - await tool.execute?.("call-1", { - action: "snapshot", - profile: "relay", - snapshotFormat: "ai", - }); - - expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( - undefined, - expect.objectContaining({ - profile: "relay", - }), - ); - }); - it("defaults to host when using profile=user (even in sandboxed sessions)", async () => { setResolvedBrowserProfiles({ user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index 54ddab2cb1f..c0111ab9977 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -290,7 +290,7 @@ function shouldPreferHostForProfile(profileName: string | undefined) { return false; } const capabilities = getBrowserProfileCapabilities(profile); - return capabilities.requiresRelay || capabilities.usesChromeMcp; + return capabilities.usesChromeMcp; } export function createBrowserTool(opts?: { @@ -307,7 +307,7 @@ export function createBrowserTool(opts?: { description: [ "Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).", "Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).", - 'For the logged-in user browser on the local host, use profile="user". Chrome (v146+) must be running. Use only when existing logins/cookies matter and the user is present.', + 'For the logged-in user browser on the local host, use profile="user". Chrome (v144+) must be running. Use only when existing logins/cookies matter and the user is present.', 'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node= or target="node".', "When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).", 'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.', @@ -326,7 +326,7 @@ export function createBrowserTool(opts?: { if (requestedNode && target && target !== "node") { throw new Error('node is only supported with target="node".'); } - // User-browser profiles (existing-session, extension relay) are host-only. + // User-browser profiles (existing-session) are host-only. const isUserBrowserProfile = shouldPreferHostForProfile(profile); if (isUserBrowserProfile) { if (requestedNode || target === "node") { diff --git a/src/browser/browser-utils.test.ts b/src/browser/browser-utils.test.ts index accd36ba7ac..5bd45952321 100644 --- a/src/browser/browser-utils.test.ts +++ b/src/browser/browser-utils.test.ts @@ -7,15 +7,10 @@ import { import { __test } from "./client-fetch.js"; import { resolveBrowserConfig, resolveProfile } from "./config.js"; import { shouldRejectBrowserMutation } from "./csrf.js"; -import { - ensureChromeExtensionRelayServer, - stopChromeExtensionRelayServer, -} from "./extension-relay.js"; import { toBoolean } from "./routes/utils.js"; import type { BrowserServerState } from "./server-context.js"; import { listKnownProfileNames } from "./server-context.js"; import { resolveTargetIdFromTabs } from "./target-id.js"; -import { getFreePort } from "./test-port.js"; describe("toBoolean", () => { it("parses yes/no and 1/0", () => { @@ -195,29 +190,8 @@ describe("cdp.helpers", () => { expect(headers.Authorization).toBe("Bearer token"); }); - it("does not add relay header for unknown loopback ports", () => { - const headers = getHeadersWithAuth("http://127.0.0.1:19444/json/version"); - expect(headers["x-openclaw-relay-token"]).toBeUndefined(); - }); - - it("adds relay header for known relay ports", async () => { - const port = await getFreePort(); - const cdpUrl = `http://127.0.0.1:${port}`; - const prev = process.env.OPENCLAW_GATEWAY_TOKEN; - process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token"; - try { - await ensureChromeExtensionRelayServer({ cdpUrl }); - const headers = getHeadersWithAuth(`${cdpUrl}/json/version`); - expect(headers["x-openclaw-relay-token"]).toBeTruthy(); - expect(headers["x-openclaw-relay-token"]).not.toBe("test-gateway-token"); - } finally { - await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {}); - if (prev === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prev; - } - } + it("does not add custom headers when none are required", () => { + expect(getHeadersWithAuth("http://127.0.0.1:19444/json/version")).toEqual({}); }); }); diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 399f0582d88..3bc02362b55 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -6,7 +6,6 @@ import { redactSensitiveText } from "../logging/redact.js"; import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js"; import { resolveBrowserRateLimitMessage } from "./client-fetch.js"; -import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js"; export { isLoopbackHost }; @@ -76,8 +75,7 @@ export type CdpSendFn = ( ) => Promise; export function getHeadersWithAuth(url: string, headers: Record = {}) { - const relayHeaders = getChromeExtensionRelayAuthHeaders(url); - const mergedHeaders = { ...relayHeaders, ...headers }; + const mergedHeaders = { ...headers }; try { const parsed = new URL(url); const hasAuthHeader = Object.keys(mergedHeaders).some( diff --git a/src/browser/chrome-extension-background-utils.test.ts b/src/browser/chrome-extension-background-utils.test.ts deleted file mode 100644 index b22b602116c..00000000000 --- a/src/browser/chrome-extension-background-utils.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { createRequire } from "node:module"; -import { describe, expect, it } from "vitest"; - -type BackgroundUtilsModule = { - buildRelayWsUrl: (port: number, gatewayToken: string) => Promise; - deriveRelayToken: (gatewayToken: string, port: number) => Promise; - isLastRemainingTab: ( - allTabs: Array<{ id?: number | undefined } | null | undefined>, - tabIdToClose: number, - ) => boolean; - isMissingTabError: (err: unknown) => boolean; - isRetryableReconnectError: (err: unknown) => boolean; - reconnectDelayMs: ( - attempt: number, - opts?: { baseMs?: number; maxMs?: number; jitterMs?: number; random?: () => number }, - ) => number; -}; - -const require = createRequire(import.meta.url); -const BACKGROUND_UTILS_MODULE = "../../assets/chrome-extension/background-utils.js"; - -async function loadBackgroundUtils(): Promise { - try { - return require(BACKGROUND_UTILS_MODULE) as BackgroundUtilsModule; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("Unexpected token 'export'")) { - throw error; - } - return (await import(BACKGROUND_UTILS_MODULE)) as BackgroundUtilsModule; - } -} - -const { - buildRelayWsUrl, - deriveRelayToken, - isLastRemainingTab, - isMissingTabError, - isRetryableReconnectError, - reconnectDelayMs, -} = await loadBackgroundUtils(); - -describe("chrome extension background utils", () => { - it("derives relay token as HMAC-SHA256 of gateway token and port", async () => { - const relayToken = await deriveRelayToken("test-gateway-token", 18792); - expect(relayToken).toMatch(/^[0-9a-f]{64}$/); - const relayToken2 = await deriveRelayToken("test-gateway-token", 18792); - expect(relayToken).toBe(relayToken2); - const differentPort = await deriveRelayToken("test-gateway-token", 9999); - expect(relayToken).not.toBe(differentPort); - }); - - it("builds websocket url with derived relay token", async () => { - const url = await buildRelayWsUrl(18792, "test-token"); - expect(url).toMatch(/^ws:\/\/127\.0\.0\.1:18792\/extension\?token=[0-9a-f]{64}$/); - }); - - it("throws when gateway token is missing", async () => { - await expect(buildRelayWsUrl(18792, "")).rejects.toThrow(/Missing gatewayToken/); - await expect(buildRelayWsUrl(18792, " ")).rejects.toThrow(/Missing gatewayToken/); - }); - - it("uses exponential backoff from attempt index", () => { - expect(reconnectDelayMs(0, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe( - 1000, - ); - expect(reconnectDelayMs(1, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe( - 2000, - ); - expect(reconnectDelayMs(4, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe( - 16000, - ); - }); - - it("caps reconnect delay at max", () => { - const delay = reconnectDelayMs(20, { - baseMs: 1000, - maxMs: 30000, - jitterMs: 0, - random: () => 0, - }); - expect(delay).toBe(30000); - }); - - it("adds jitter using injected random source", () => { - const delay = reconnectDelayMs(3, { - baseMs: 1000, - maxMs: 30000, - jitterMs: 1000, - random: () => 0.25, - }); - expect(delay).toBe(8250); - }); - - it("sanitizes invalid attempts and options", () => { - expect(reconnectDelayMs(-2, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe( - 1000, - ); - expect( - reconnectDelayMs(Number.NaN, { - baseMs: Number.NaN, - maxMs: Number.NaN, - jitterMs: Number.NaN, - random: () => 0, - }), - ).toBe(1000); - }); - - it("marks missing token errors as non-retryable", () => { - expect( - isRetryableReconnectError( - new Error("Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)"), - ), - ).toBe(false); - }); - - it("keeps transient network errors retryable", () => { - expect(isRetryableReconnectError(new Error("WebSocket connect timeout"))).toBe(true); - expect(isRetryableReconnectError(new Error("Relay server not reachable"))).toBe(true); - }); - - it("recognizes missing-tab debugger errors", () => { - expect(isMissingTabError(new Error("No tab with given id"))).toBe(true); - expect(isMissingTabError(new Error("tab not found"))).toBe(true); - expect(isMissingTabError(new Error("Cannot access a chrome:// URL"))).toBe(false); - }); - - it("blocks closing the final remaining tab only", () => { - expect(isLastRemainingTab([{ id: 7 }], 7)).toBe(true); - expect(isLastRemainingTab([{ id: 7 }, { id: 8 }], 7)).toBe(false); - expect(isLastRemainingTab([{ id: 7 }, { id: 8 }], 8)).toBe(false); - }); -}); diff --git a/src/browser/chrome-extension-manifest.test.ts b/src/browser/chrome-extension-manifest.test.ts deleted file mode 100644 index 4d4a0321724..00000000000 --- a/src/browser/chrome-extension-manifest.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; - -type ExtensionManifest = { - background?: { service_worker?: string; type?: string }; - permissions?: string[]; -}; - -function readManifest(): ExtensionManifest { - const path = resolve(process.cwd(), "assets/chrome-extension/manifest.json"); - return JSON.parse(readFileSync(path, "utf8")) as ExtensionManifest; -} - -describe("chrome extension manifest", () => { - it("keeps background worker configured as module", () => { - const manifest = readManifest(); - expect(manifest.background?.service_worker).toBe("background.js"); - expect(manifest.background?.type).toBe("module"); - }); - - it("includes resilience permissions", () => { - const permissions = readManifest().permissions ?? []; - expect(permissions).toContain("alarms"); - expect(permissions).toContain("webNavigation"); - expect(permissions).toContain("storage"); - expect(permissions).toContain("debugger"); - }); -}); diff --git a/src/browser/chrome-extension-options-validation.test.ts b/src/browser/chrome-extension-options-validation.test.ts deleted file mode 100644 index 23aa6d1ce06..00000000000 --- a/src/browser/chrome-extension-options-validation.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { createRequire } from "node:module"; -import { describe, expect, it } from "vitest"; - -type RelayCheckResponse = { - status?: number; - ok?: boolean; - error?: string; - contentType?: string; - json?: unknown; -}; - -type RelayCheckStatus = - | { action: "throw"; error: string } - | { action: "status"; kind: "ok" | "error"; message: string }; - -type RelayCheckExceptionStatus = { kind: "error"; message: string }; - -type OptionsValidationModule = { - classifyRelayCheckResponse: ( - res: RelayCheckResponse | null | undefined, - port: number, - ) => RelayCheckStatus; - classifyRelayCheckException: (err: unknown, port: number) => RelayCheckExceptionStatus; -}; - -const require = createRequire(import.meta.url); -const OPTIONS_VALIDATION_MODULE = "../../assets/chrome-extension/options-validation.js"; - -async function loadOptionsValidation(): Promise { - try { - return require(OPTIONS_VALIDATION_MODULE) as OptionsValidationModule; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("Unexpected token 'export'")) { - throw error; - } - return (await import(OPTIONS_VALIDATION_MODULE)) as OptionsValidationModule; - } -} - -const { classifyRelayCheckException, classifyRelayCheckResponse } = await loadOptionsValidation(); - -describe("chrome extension options validation", () => { - it("maps 401 response to token rejected error", () => { - const result = classifyRelayCheckResponse({ status: 401, ok: false }, 18792); - expect(result).toEqual({ - action: "status", - kind: "error", - message: "Gateway token rejected. Check token and save again.", - }); - }); - - it("maps non-json 200 response to wrong-port error", () => { - const result = classifyRelayCheckResponse( - { status: 200, ok: true, contentType: "text/html; charset=utf-8", json: null }, - 18792, - ); - expect(result).toEqual({ - action: "status", - kind: "error", - message: - "Wrong port: this is likely the gateway, not the relay. Use gateway port + 3 (for gateway 18789, relay is 18792).", - }); - }); - - it("maps json response without CDP keys to wrong-port error", () => { - const result = classifyRelayCheckResponse( - { status: 200, ok: true, contentType: "application/json", json: { ok: true } }, - 18792, - ); - expect(result).toEqual({ - action: "status", - kind: "error", - message: - "Wrong port: expected relay /json/version response. Use gateway port + 3 (for gateway 18789, relay is 18792).", - }); - }); - - it("maps valid relay json response to success", () => { - const result = classifyRelayCheckResponse( - { - status: 200, - ok: true, - contentType: "application/json", - json: { Browser: "Chrome/136", "Protocol-Version": "1.3" }, - }, - 19004, - ); - expect(result).toEqual({ - action: "status", - kind: "ok", - message: "Relay reachable and authenticated at http://127.0.0.1:19004/", - }); - }); - - it("maps syntax/json exceptions to wrong-endpoint error", () => { - const result = classifyRelayCheckException(new Error("SyntaxError: Unexpected token <"), 18792); - expect(result).toEqual({ - kind: "error", - message: - "Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).", - }); - }); - - it("maps generic exceptions to relay unreachable error", () => { - const result = classifyRelayCheckException(new Error("TypeError: Failed to fetch"), 18792); - expect(result).toEqual({ - kind: "error", - message: - "Relay not reachable/authenticated at http://127.0.0.1:18792/. Start OpenClaw browser relay and verify token.", - }); - }); -}); diff --git a/src/browser/chrome-mcp.ts b/src/browser/chrome-mcp.ts index c649fe53633..a673feb2c27 100644 --- a/src/browser/chrome-mcp.ts +++ b/src/browser/chrome-mcp.ts @@ -193,7 +193,7 @@ async function createRealSession(profileName: string): Promise await client.close().catch(() => {}); throw new BrowserProfileUnavailableError( `Chrome MCP existing-session attach failed for profile "${profileName}". ` + - `Make sure Chrome (v146+) is running. ` + + `Make sure Chrome (v144+) is running. ` + `Details: ${String(err)}`, ); } diff --git a/src/browser/chrome.executables.ts b/src/browser/chrome.executables.ts index 729127c9df9..6ef7bc0b155 100644 --- a/src/browser/chrome.executables.ts +++ b/src/browser/chrome.executables.ts @@ -9,6 +9,8 @@ export type BrowserExecutable = { path: string; }; +const CHROME_VERSION_RE = /(\d+)(?:\.\d+){0,3}/; + const CHROMIUM_BUNDLE_IDS = new Set([ "com.google.Chrome", "com.google.Chrome.beta", @@ -453,6 +455,22 @@ function findFirstExecutable(candidates: Array): BrowserExecu return null; } +function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null { + for (const candidate of candidates) { + if (exists(candidate)) { + return { + kind: + candidate.toLowerCase().includes("sxs") || candidate.toLowerCase().includes("canary") + ? "canary" + : "chrome", + path: candidate, + }; + } + } + + return null; +} + export function findChromeExecutableMac(): BrowserExecutable | null { const candidates: Array = [ { @@ -506,6 +524,18 @@ export function findChromeExecutableMac(): BrowserExecutable | null { return findFirstExecutable(candidates); } +export function findGoogleChromeExecutableMac(): BrowserExecutable | null { + return findFirstChromeExecutable([ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + path.join( + os.homedir(), + "Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + ), + ]); +} + export function findChromeExecutableLinux(): BrowserExecutable | null { const candidates: Array = [ { kind: "chrome", path: "/usr/bin/google-chrome" }, @@ -525,6 +555,16 @@ export function findChromeExecutableLinux(): BrowserExecutable | null { return findFirstExecutable(candidates); } +export function findGoogleChromeExecutableLinux(): BrowserExecutable | null { + return findFirstChromeExecutable([ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome-beta", + "/usr/bin/google-chrome-unstable", + "/snap/bin/google-chrome", + ]); +} + export function findChromeExecutableWindows(): BrowserExecutable | null { const localAppData = process.env.LOCALAPPDATA ?? ""; const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; @@ -596,6 +636,56 @@ export function findChromeExecutableWindows(): BrowserExecutable | null { return findFirstExecutable(candidates); } +export function findGoogleChromeExecutableWindows(): BrowserExecutable | null { + const localAppData = process.env.LOCALAPPDATA ?? ""; + const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; + const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; + const joinWin = path.win32.join; + const candidates: string[] = []; + + if (localAppData) { + candidates.push(joinWin(localAppData, "Google", "Chrome", "Application", "chrome.exe")); + candidates.push(joinWin(localAppData, "Google", "Chrome SxS", "Application", "chrome.exe")); + } + + candidates.push(joinWin(programFiles, "Google", "Chrome", "Application", "chrome.exe")); + candidates.push(joinWin(programFilesX86, "Google", "Chrome", "Application", "chrome.exe")); + + return findFirstChromeExecutable(candidates); +} + +export function resolveGoogleChromeExecutableForPlatform( + platform: NodeJS.Platform, +): BrowserExecutable | null { + if (platform === "darwin") { + return findGoogleChromeExecutableMac(); + } + if (platform === "linux") { + return findGoogleChromeExecutableLinux(); + } + if (platform === "win32") { + return findGoogleChromeExecutableWindows(); + } + return null; +} + +export function readBrowserVersion(executablePath: string): string | null { + const output = execText(executablePath, ["--version"], 2000); + if (!output) { + return null; + } + return output.replace(/\s+/g, " ").trim(); +} + +export function parseBrowserMajorVersion(rawVersion: string | null | undefined): number | null { + const match = String(rawVersion ?? "").match(CHROME_VERSION_RE); + if (!match?.[1]) { + return null; + } + const major = Number.parseInt(match[1], 10); + return Number.isFinite(major) ? major : null; +} + export function resolveBrowserExecutableForPlatform( resolved: ResolvedBrowserConfig, platform: NodeJS.Platform, diff --git a/src/browser/client.ts b/src/browser/client.ts index 8e30762bfb1..7791b4405be 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -5,7 +5,7 @@ export type BrowserTransport = "cdp" | "chrome-mcp"; export type BrowserStatus = { enabled: boolean; profile?: string; - driver?: "openclaw" | "extension" | "existing-session"; + driver?: "openclaw" | "existing-session"; transport?: BrowserTransport; running: boolean; cdpReady?: boolean; @@ -31,7 +31,7 @@ export type ProfileStatus = { cdpPort: number | null; cdpUrl: string | null; color: string; - driver: "openclaw" | "extension" | "existing-session"; + driver: "openclaw" | "existing-session"; running: boolean; tabCount: number; isDefault: boolean; @@ -172,7 +172,7 @@ export async function browserCreateProfile( name: string; color?: string; cdpUrl?: string; - driver?: "openclaw" | "extension" | "existing-session"; + driver?: "openclaw" | "existing-session"; }, ): Promise { return await fetchBrowserJson( diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index 947cf10c0fa..7f80c4389a1 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -188,13 +188,6 @@ describe("browser config", () => { expect(profile?.cdpIsLoopback).toBe(true); }); - it("trims relayBindHost when configured", () => { - const resolved = resolveBrowserConfig({ - relayBindHost: " 0.0.0.0 ", - }); - expect(resolved.relayBindHost).toBe("0.0.0.0"); - }); - it("rejects unsupported protocols", () => { expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow( "must be http(s) or ws(s)", @@ -289,7 +282,6 @@ describe("browser config", () => { const resolved = resolveBrowserConfig({ profiles: { "chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" }, - relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" }, work: { cdpPort: 18801, color: "#0066CC" }, }, }); @@ -300,9 +292,6 @@ describe("browser config", () => { const managed = resolveProfile(resolved, "openclaw")!; expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false); - const extension = resolveProfile(resolved, "relay")!; - expect(getBrowserProfileCapabilities(extension).usesChromeMcp).toBe(false); - const work = resolveProfile(resolved, "work")!; expect(getBrowserProfileCapabilities(work).usesChromeMcp).toBe(false); }); diff --git a/src/browser/config.ts b/src/browser/config.ts index e535b926a96..64fffce865c 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -36,7 +36,6 @@ export type ResolvedBrowserConfig = { profiles: Record; ssrfPolicy?: SsrFPolicy; extraArgs: string[]; - relayBindHost?: string; }; export type ResolvedBrowserProfile = { @@ -46,7 +45,7 @@ export type ResolvedBrowserProfile = { cdpHost: string; cdpIsLoopback: boolean; color: string; - driver: "openclaw" | "extension" | "existing-session"; + driver: "openclaw" | "existing-session"; attachOnly: boolean; }; @@ -279,8 +278,6 @@ export function resolveBrowserConfig( ? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0) : []; const ssrfPolicy = resolveBrowserSsrFPolicy(cfg); - const relayBindHost = cfg?.relayBindHost?.trim() || undefined; - return { enabled, evaluateEnabled, @@ -301,7 +298,6 @@ export function resolveBrowserConfig( profiles, ssrfPolicy, extraArgs, - relayBindHost, }; } @@ -322,12 +318,7 @@ export function resolveProfile( let cdpHost = resolved.cdpHost; let cdpPort = profile.cdpPort ?? 0; let cdpUrl = ""; - const driver = - profile.driver === "extension" - ? "extension" - : profile.driver === "existing-session" - ? "existing-session" - : "openclaw"; + const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw"; if (driver === "existing-session") { // existing-session uses Chrome MCP auto-connect; no CDP port/URL needed diff --git a/src/browser/extension-relay-auth.secretref.test.ts b/src/browser/extension-relay-auth.secretref.test.ts deleted file mode 100644 index 7976064f35e..00000000000 --- a/src/browser/extension-relay-auth.secretref.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const loadConfigMock = vi.hoisted(() => vi.fn()); - -vi.mock("../config/config.js", () => ({ - loadConfig: loadConfigMock, -})); - -const { resolveRelayAcceptedTokensForPort } = await import("./extension-relay-auth.js"); - -describe("extension-relay-auth SecretRef handling", () => { - const ENV_KEYS = ["OPENCLAW_GATEWAY_TOKEN", "CLAWDBOT_GATEWAY_TOKEN", "CUSTOM_GATEWAY_TOKEN"]; - const envSnapshot = new Map(); - - beforeEach(() => { - for (const key of ENV_KEYS) { - envSnapshot.set(key, process.env[key]); - delete process.env[key]; - } - loadConfigMock.mockReset(); - }); - - afterEach(() => { - for (const key of ENV_KEYS) { - const previous = envSnapshot.get(key); - if (previous === undefined) { - delete process.env[key]; - } else { - process.env[key] = previous; - } - } - }); - - it("resolves env-template gateway.auth.token from its referenced env var", async () => { - loadConfigMock.mockReturnValue({ - gateway: { auth: { token: "${CUSTOM_GATEWAY_TOKEN}" } }, - secrets: { providers: { default: { source: "env" } } }, - }); - process.env.CUSTOM_GATEWAY_TOKEN = "resolved-gateway-token"; - - const tokens = await resolveRelayAcceptedTokensForPort(18790); - - expect(tokens).toContain("resolved-gateway-token"); - expect(tokens[0]).not.toBe("resolved-gateway-token"); - }); - - it("fails closed when env-template gateway.auth.token is unresolved", async () => { - loadConfigMock.mockReturnValue({ - gateway: { auth: { token: "${CUSTOM_GATEWAY_TOKEN}" } }, - secrets: { providers: { default: { source: "env" } } }, - }); - - await expect(resolveRelayAcceptedTokensForPort(18790)).rejects.toThrow( - "gateway.auth.token SecretRef is unavailable", - ); - }); - - it("resolves file-backed gateway.auth.token SecretRef", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-relay-file-secret-")); - const secretFile = path.join(tempDir, "relay-secrets.json"); - await fs.writeFile(secretFile, JSON.stringify({ relayToken: "resolved-file-relay-token" })); - await fs.chmod(secretFile, 0o600); - - loadConfigMock.mockReturnValue({ - secrets: { - providers: { - fileProvider: { source: "file", path: secretFile, mode: "json" }, - }, - }, - gateway: { - auth: { - token: { source: "file", provider: "fileProvider", id: "/relayToken" }, - }, - }, - }); - - try { - const tokens = await resolveRelayAcceptedTokensForPort(18790); - expect(tokens.length).toBeGreaterThan(0); - expect(tokens).toContain("resolved-file-relay-token"); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } - }); - - it("resolves exec-backed gateway.auth.token SecretRef", async () => { - const execProgram = [ - "process.stdout.write(", - "JSON.stringify({ protocolVersion: 1, values: { RELAY_TOKEN: 'resolved-exec-relay-token' } })", - ");", - ].join(""); - loadConfigMock.mockReturnValue({ - secrets: { - providers: { - execProvider: { - source: "exec", - command: process.execPath, - args: ["-e", execProgram], - allowInsecurePath: true, - }, - }, - }, - gateway: { - auth: { - token: { source: "exec", provider: "execProvider", id: "RELAY_TOKEN" }, - }, - }, - }); - - const tokens = await resolveRelayAcceptedTokensForPort(18790); - expect(tokens.length).toBeGreaterThan(0); - expect(tokens).toContain("resolved-exec-relay-token"); - }); -}); diff --git a/src/browser/extension-relay-auth.test.ts b/src/browser/extension-relay-auth.test.ts deleted file mode 100644 index c052e31a209..00000000000 --- a/src/browser/extension-relay-auth.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import type { AddressInfo } from "node:net"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - probeAuthenticatedOpenClawRelay, - resolveRelayAcceptedTokensForPort, - resolveRelayAuthTokenForPort, -} from "./extension-relay-auth.js"; -import { getFreePort } from "./test-port.js"; - -async function withRelayServer( - handler: (req: IncomingMessage, res: ServerResponse) => void, - run: (params: { port: number }) => Promise, -) { - const port = await getFreePort(); - const server = createServer(handler); - await new Promise((resolve, reject) => { - server.listen(port, "127.0.0.1", () => resolve()); - server.once("error", reject); - }); - try { - const actualPort = (server.address() as AddressInfo).port; - await run({ port: actualPort }); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - } -} - -function handleNonVersionRequest(req: IncomingMessage, res: ServerResponse): boolean { - if (req.url?.startsWith("/json/version")) { - return false; - } - res.writeHead(404); - res.end("not found"); - return true; -} - -async function probeRelay(baseUrl: string, relayAuthToken: string): Promise { - return await probeAuthenticatedOpenClawRelay({ - baseUrl, - relayAuthHeader: "x-openclaw-relay-token", - relayAuthToken, - }); -} - -describe("extension-relay-auth", () => { - const TEST_GATEWAY_TOKEN = "test-gateway-token"; - let prevGatewayToken: string | undefined; - - beforeEach(() => { - prevGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; - process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN; - }); - - afterEach(() => { - if (prevGatewayToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevGatewayToken; - } - }); - - it("derives deterministic relay tokens per port", async () => { - const tokenA1 = await resolveRelayAuthTokenForPort(18790); - const tokenA2 = await resolveRelayAuthTokenForPort(18790); - const tokenB = await resolveRelayAuthTokenForPort(18791); - expect(tokenA1).toBe(tokenA2); - expect(tokenA1).not.toBe(tokenB); - expect(tokenA1).not.toBe(TEST_GATEWAY_TOKEN); - }); - - it("accepts both relay-scoped and raw gateway tokens for compatibility", async () => { - const tokens = await resolveRelayAcceptedTokensForPort(18790); - expect(tokens).toContain(TEST_GATEWAY_TOKEN); - expect(tokens[0]).not.toBe(TEST_GATEWAY_TOKEN); - expect(tokens[0]).toBe(await resolveRelayAuthTokenForPort(18790)); - }); - - it("accepts authenticated openclaw relay probe responses", async () => { - let seenToken: string | undefined; - await withRelayServer( - (req, res) => { - if (handleNonVersionRequest(req, res)) { - return; - } - const header = req.headers["x-openclaw-relay-token"]; - seenToken = Array.isArray(header) ? header[0] : header; - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" })); - }, - async ({ port }) => { - const token = await resolveRelayAuthTokenForPort(port); - const ok = await probeRelay(`http://127.0.0.1:${port}`, token); - expect(ok).toBe(true); - expect(seenToken).toBe(token); - }, - ); - }); - - it("rejects unauthenticated probe responses", async () => { - await withRelayServer( - (req, res) => { - if (handleNonVersionRequest(req, res)) { - return; - } - res.writeHead(401); - res.end("Unauthorized"); - }, - async ({ port }) => { - const ok = await probeRelay(`http://127.0.0.1:${port}`, "irrelevant"); - expect(ok).toBe(false); - }, - ); - }); - - it("rejects probe responses with wrong browser identity", async () => { - await withRelayServer( - (req, res) => { - if (handleNonVersionRequest(req, res)) { - return; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ Browser: "FakeRelay" })); - }, - async ({ port }) => { - const ok = await probeRelay(`http://127.0.0.1:${port}`, "irrelevant"); - expect(ok).toBe(false); - }, - ); - }); -}); diff --git a/src/browser/extension-relay-auth.ts b/src/browser/extension-relay-auth.ts deleted file mode 100644 index 7143a6c716e..00000000000 --- a/src/browser/extension-relay-auth.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { createHmac } from "node:crypto"; -import { loadConfig } from "../config/config.js"; -import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js"; -import { secretRefKey } from "../secrets/ref-contract.js"; -import { resolveSecretRefValues } from "../secrets/resolve.js"; - -const RELAY_TOKEN_CONTEXT = "openclaw-extension-relay-v1"; -const DEFAULT_RELAY_PROBE_TIMEOUT_MS = 500; -const OPENCLAW_RELAY_BROWSER = "OpenClaw/extension-relay"; - -class SecretRefUnavailableError extends Error { - readonly isSecretRefUnavailable = true; -} - -function trimToUndefined(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -async function resolveGatewayAuthToken(): Promise { - const envToken = - process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim(); - if (envToken) { - return envToken; - } - try { - const cfg = loadConfig(); - const tokenRef = resolveSecretInputRef({ - value: cfg.gateway?.auth?.token, - defaults: cfg.secrets?.defaults, - }).ref; - if (tokenRef) { - const refLabel = `${tokenRef.source}:${tokenRef.provider}:${tokenRef.id}`; - try { - const resolved = await resolveSecretRefValues([tokenRef], { - config: cfg, - env: process.env, - }); - const resolvedToken = trimToUndefined(resolved.get(secretRefKey(tokenRef))); - if (resolvedToken) { - return resolvedToken; - } - } catch { - // handled below - } - throw new SecretRefUnavailableError( - `extension relay requires a resolved gateway token, but gateway.auth.token SecretRef is unavailable (${refLabel}). Set OPENCLAW_GATEWAY_TOKEN or resolve your secret provider.`, - ); - } - const configToken = normalizeSecretInputString(cfg.gateway?.auth?.token); - if (configToken) { - return configToken; - } - } catch (err) { - if (err instanceof SecretRefUnavailableError) { - throw err; - } - // ignore config read failures; caller can fallback to per-process random token - } - return null; -} - -function deriveRelayAuthToken(gatewayToken: string, port: number): string { - return createHmac("sha256", gatewayToken).update(`${RELAY_TOKEN_CONTEXT}:${port}`).digest("hex"); -} - -export async function resolveRelayAcceptedTokensForPort(port: number): Promise { - const gatewayToken = await resolveGatewayAuthToken(); - if (!gatewayToken) { - throw new Error( - "extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)", - ); - } - const relayToken = deriveRelayAuthToken(gatewayToken, port); - if (relayToken === gatewayToken) { - return [relayToken]; - } - return [relayToken, gatewayToken]; -} - -export async function resolveRelayAuthTokenForPort(port: number): Promise { - return (await resolveRelayAcceptedTokensForPort(port))[0]; -} - -export async function probeAuthenticatedOpenClawRelay(params: { - baseUrl: string; - relayAuthHeader: string; - relayAuthToken: string; - timeoutMs?: number; -}): Promise { - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), params.timeoutMs ?? DEFAULT_RELAY_PROBE_TIMEOUT_MS); - try { - const versionUrl = new URL("/json/version", `${params.baseUrl}/`).toString(); - const res = await fetch(versionUrl, { - signal: ctrl.signal, - headers: { [params.relayAuthHeader]: params.relayAuthToken }, - }); - if (!res.ok) { - return false; - } - const body = (await res.json()) as { Browser?: unknown }; - const browserName = typeof body?.Browser === "string" ? body.Browser.trim() : ""; - return browserName === OPENCLAW_RELAY_BROWSER; - } catch { - return false; - } finally { - clearTimeout(timer); - } -} diff --git a/src/browser/extension-relay.bind-host.test.ts b/src/browser/extension-relay.bind-host.test.ts deleted file mode 100644 index a029a2f1a95..00000000000 --- a/src/browser/extension-relay.bind-host.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { captureEnv } from "../test-utils/env.js"; -import { - ensureChromeExtensionRelayServer, - stopChromeExtensionRelayServer, -} from "./extension-relay.js"; -import { getFreePort } from "./test-port.js"; - -describe("chrome extension relay bindHost coordination", () => { - let cdpUrl = ""; - let envSnapshot: ReturnType; - - beforeEach(() => { - envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]); - process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token"; - }); - - afterEach(async () => { - if (cdpUrl) { - await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {}); - cdpUrl = ""; - } - envSnapshot.restore(); - }); - - it("rebinds the relay when concurrent callers request different bind hosts", async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - - const [first, second] = await Promise.all([ - ensureChromeExtensionRelayServer({ cdpUrl }), - ensureChromeExtensionRelayServer({ cdpUrl, bindHost: "0.0.0.0" }), - ]); - - const settled = await ensureChromeExtensionRelayServer({ - cdpUrl, - bindHost: "0.0.0.0", - }); - - expect(first.port).toBe(port); - expect(second.port).toBe(port); - expect(second).not.toBe(first); - expect(second.bindHost).toBe("0.0.0.0"); - expect(settled).toBe(second); - - const res = await fetch(`http://127.0.0.1:${port}/`); - expect(res.status).toBe(200); - }); -}); diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts deleted file mode 100644 index f6e14ee8803..00000000000 --- a/src/browser/extension-relay.test.ts +++ /dev/null @@ -1,1224 +0,0 @@ -import { createServer } from "node:http"; -import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; -import WebSocket from "ws"; -import { captureEnv } from "../test-utils/env.js"; -import { - ensureChromeExtensionRelayServer, - getChromeExtensionRelayAuthHeaders, - stopChromeExtensionRelayServer, -} from "./extension-relay.js"; -import { getFreePort } from "./test-port.js"; - -const RELAY_MESSAGE_TIMEOUT_MS = 1_200; -const RELAY_LIST_MATCH_TIMEOUT_MS = 1_000; -const RELAY_TEST_TIMEOUT_MS = 10_000; - -function waitForOpen(ws: WebSocket) { - return new Promise((resolve, reject) => { - ws.once("open", () => resolve()); - ws.once("error", reject); - }); -} - -function waitForError(ws: WebSocket) { - return new Promise((resolve, reject) => { - ws.once("error", (err) => resolve(err instanceof Error ? err : new Error(String(err)))); - ws.once("open", () => reject(new Error("expected websocket error"))); - }); -} - -function waitForClose(ws: WebSocket, timeoutMs = RELAY_MESSAGE_TIMEOUT_MS) { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error("timeout")); - }, timeoutMs); - ws.once("close", () => { - clearTimeout(timer); - resolve(); - }); - ws.once("error", (err) => { - clearTimeout(timer); - reject(err instanceof Error ? err : new Error(String(err))); - }); - }); -} - -function relayAuthHeaders(url: string) { - return getChromeExtensionRelayAuthHeaders(url); -} - -function createMessageQueue(ws: WebSocket) { - const queue: string[] = []; - let waiter: ((value: string) => void) | null = null; - let waiterReject: ((err: Error) => void) | null = null; - let waiterTimer: NodeJS.Timeout | null = null; - - const flushWaiter = (value: string) => { - if (!waiter) { - return false; - } - const resolve = waiter; - waiter = null; - const reject = waiterReject; - waiterReject = null; - if (waiterTimer) { - clearTimeout(waiterTimer); - } - waiterTimer = null; - if (reject) { - // no-op (kept for symmetry) - } - resolve(value); - return true; - }; - - ws.on("message", (data) => { - const text = - typeof data === "string" - ? data - : Buffer.isBuffer(data) - ? data.toString("utf8") - : Array.isArray(data) - ? Buffer.concat(data).toString("utf8") - : Buffer.from(data).toString("utf8"); - if (flushWaiter(text)) { - return; - } - queue.push(text); - }); - - ws.on("error", (err) => { - if (!waiterReject) { - return; - } - const reject = waiterReject; - waiterReject = null; - waiter = null; - if (waiterTimer) { - clearTimeout(waiterTimer); - } - waiterTimer = null; - reject(err instanceof Error ? err : new Error(String(err))); - }); - - const next = (timeoutMs = RELAY_MESSAGE_TIMEOUT_MS) => - new Promise((resolve, reject) => { - const existing = queue.shift(); - if (existing !== undefined) { - return resolve(existing); - } - waiter = resolve; - waiterReject = reject; - waiterTimer = setTimeout(() => { - waiter = null; - waiterReject = null; - waiterTimer = null; - reject(new Error("timeout")); - }, timeoutMs); - }); - - return { next }; -} - -async function waitForListMatch( - fetchList: () => Promise, - predicate: (value: T) => boolean, - timeoutMs = RELAY_LIST_MATCH_TIMEOUT_MS, - intervalMs = 20, -): Promise { - const deadline = Date.now() + timeoutMs; - let latest: T | null = null; - while (Date.now() <= deadline) { - latest = await fetchList(); - if (predicate(latest)) { - return latest; - } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - throw new Error("timeout waiting for list match"); -} - -describe("chrome extension relay server", () => { - const TEST_GATEWAY_TOKEN = "test-gateway-token"; - let cdpUrl = ""; - let sharedCdpUrl = ""; - let envSnapshot: ReturnType; - - beforeEach(() => { - envSnapshot = captureEnv([ - "OPENCLAW_GATEWAY_TOKEN", - "OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS", - "OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS", - ]); - process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN; - delete process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS; - delete process.env.OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS; - }); - - afterEach(async () => { - if (cdpUrl) { - await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {}); - cdpUrl = ""; - } - envSnapshot.restore(); - }); - - afterAll(async () => { - if (!sharedCdpUrl) { - return; - } - await stopChromeExtensionRelayServer({ cdpUrl: sharedCdpUrl }).catch(() => {}); - sharedCdpUrl = ""; - }); - - async function ensureSharedRelayServer() { - if (sharedCdpUrl) { - return sharedCdpUrl; - } - const port = await getFreePort(); - sharedCdpUrl = `http://127.0.0.1:${port}`; - await ensureChromeExtensionRelayServer({ cdpUrl: sharedCdpUrl }); - return sharedCdpUrl; - } - - async function startRelayWithExtension() { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - await ensureChromeExtensionRelayServer({ cdpUrl }); - const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext); - return { port, ext }; - } - - it("advertises CDP WS only when extension is connected", async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - await ensureChromeExtensionRelayServer({ cdpUrl }); - - const v1 = (await fetch(`${cdpUrl}/json/version`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as { - webSocketDebuggerUrl?: string; - }; - expect(v1.webSocketDebuggerUrl).toBeUndefined(); - - const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext); - - const v2 = (await fetch(`${cdpUrl}/json/version`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as { - webSocketDebuggerUrl?: string; - }; - expect(String(v2.webSocketDebuggerUrl ?? "")).toContain(`/cdp`); - - ext.close(); - }); - - it("uses relay-scoped token only for known relay ports", async () => { - const port = await getFreePort(); - const unknown = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`); - expect(unknown).toEqual({}); - - const sharedUrl = await ensureSharedRelayServer(); - - const headers = getChromeExtensionRelayAuthHeaders(sharedUrl); - expect(Object.keys(headers)).toContain("x-openclaw-relay-token"); - expect(headers["x-openclaw-relay-token"]).not.toBe(TEST_GATEWAY_TOKEN); - }); - - it("rejects CDP access without relay auth token", async () => { - const sharedUrl = await ensureSharedRelayServer(); - const sharedPort = new URL(sharedUrl).port; - - const res = await fetch(`${sharedUrl}/json/version`); - expect(res.status).toBe(401); - - const cdp = new WebSocket(`ws://127.0.0.1:${sharedPort}/cdp`); - const err = await waitForError(cdp); - expect(err.message).toContain("401"); - }); - - it("returns 400 for malformed percent-encoding in target action routes", async () => { - const sharedUrl = await ensureSharedRelayServer(); - - const res = await fetch(`${sharedUrl}/json/activate/%E0%A4%A`, { - headers: relayAuthHeaders(sharedUrl), - }); - expect(res.status).toBe(400); - expect(await res.text()).toContain("invalid targetId encoding"); - }); - - it("deduplicates concurrent relay starts for the same requested port", async () => { - const sharedUrl = await ensureSharedRelayServer(); - const port = Number(new URL(sharedUrl).port); - const [first, second] = await Promise.all([ - ensureChromeExtensionRelayServer({ cdpUrl: sharedUrl }), - ensureChromeExtensionRelayServer({ cdpUrl: sharedUrl }), - ]); - expect(first).toBe(second); - expect(first.port).toBe(port); - }); - - it("allows CORS preflight from chrome-extension origins", async () => { - const sharedUrl = await ensureSharedRelayServer(); - - const origin = "chrome-extension://abcdefghijklmnop"; - const res = await fetch(`${sharedUrl}/json/version`, { - method: "OPTIONS", - headers: { - Origin: origin, - "Access-Control-Request-Method": "GET", - "Access-Control-Request-Headers": "x-openclaw-relay-token", - }, - }); - - expect(res.status).toBe(204); - expect(res.headers.get("access-control-allow-origin")).toBe(origin); - expect(res.headers.get("access-control-allow-headers") ?? "").toContain( - "x-openclaw-relay-token", - ); - }); - - it("rejects CORS preflight from non-extension origins", async () => { - const sharedUrl = await ensureSharedRelayServer(); - - const res = await fetch(`${sharedUrl}/json/version`, { - method: "OPTIONS", - headers: { - Origin: "https://example.com", - "Access-Control-Request-Method": "GET", - }, - }); - - expect(res.status).toBe(403); - }); - - it("returns CORS headers on JSON responses for extension origins", async () => { - const sharedUrl = await ensureSharedRelayServer(); - - const origin = "chrome-extension://abcdefghijklmnop"; - const res = await fetch(`${sharedUrl}/json/version`, { - headers: { - Origin: origin, - ...relayAuthHeaders(sharedUrl), - }, - }); - - expect(res.status).toBe(200); - expect(res.headers.get("access-control-allow-origin")).toBe(origin); - }); - - it("rejects extension websocket access without relay auth token", async () => { - const sharedUrl = await ensureSharedRelayServer(); - const sharedPort = new URL(sharedUrl).port; - - const ext = new WebSocket(`ws://127.0.0.1:${sharedPort}/extension`); - const err = await waitForError(ext); - expect(err.message).toContain("401"); - }); - - it("rejects a second live extension connection with 409", async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - await ensureChromeExtensionRelayServer({ cdpUrl }); - - const ext1 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext1); - - const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - const err = await waitForError(ext2); - expect(err.message).toContain("409"); - - ext1.close(); - }); - - it("allows immediate reconnect when prior extension socket is closing", async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - await ensureChromeExtensionRelayServer({ cdpUrl }); - - const ext1 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext1); - const ext1Closed = new Promise((resolve) => ext1.once("close", () => resolve())); - - ext1.close(); - const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext2); - await ext1Closed; - - const status = (await fetch(`${cdpUrl}/extension/status`).then((r) => r.json())) as { - connected?: boolean; - }; - expect(status.connected).toBe(true); - - ext2.close(); - }); - - it("keeps CDP clients alive across a brief extension reconnect", async () => { - const { port, ext: ext1 } = await startRelayWithExtension(); - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - - let cdpClosed = false; - cdp.once("close", () => { - cdpClosed = true; - }); - - const ext1Closed = waitForClose(ext1, 2_000); - ext1.close(); - await ext1Closed; - const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext2); - expect(cdpClosed).toBe(false); - - cdp.close(); - ext2.close(); - }); - - it("keeps /json/version websocket endpoint during short extension disconnects", async () => { - const { port, ext } = await startRelayWithExtension(); - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-disconnect", - targetInfo: { - targetId: "t-disconnect", - type: "page", - title: "Disconnect test", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.some((entry) => entry.id === "t-disconnect"), - ); - - const extClosed = waitForClose(ext, 2_000); - ext.close(); - await extClosed; - - const version = (await fetch(`${cdpUrl}/json/version`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as { - webSocketDebuggerUrl?: string; - }; - expect(String(version.webSocketDebuggerUrl ?? "")).toContain("/cdp"); - - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - cdp.close(); - }); - - it("accepts re-announce attach events with minimal targetInfo", async () => { - const { ext } = await startRelayWithExtension(); - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-minimal", - targetInfo: { - targetId: "t-minimal", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (entries) => entries.some((entry) => entry.id === "t-minimal"), - ); - }); - - it("waits briefly for extension reconnect before failing CDP commands", async () => { - const { port, ext: ext1 } = await startRelayWithExtension(); - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - const cdpQueue = createMessageQueue(cdp); - - const ext1Closed = waitForClose(ext1, 2_000); - ext1.close(); - await ext1Closed; - - cdp.send(JSON.stringify({ id: 41, method: "Runtime.enable" })); - await new Promise((r) => setTimeout(r, 30)); - - const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - const ext2Queue = createMessageQueue(ext2); - await waitForOpen(ext2); - - while (true) { - const msg = JSON.parse(await ext2Queue.next(4_000)) as { - id?: number; - method?: string; - }; - if (msg.method === "ping") { - ext2.send(JSON.stringify({ method: "pong" })); - continue; - } - if (msg.method === "forwardCDPCommand" && typeof msg.id === "number") { - ext2.send(JSON.stringify({ id: msg.id, result: { ok: true } })); - break; - } - } - - const response = JSON.parse(await cdpQueue.next(6_000)) as { - id?: number; - result?: { ok?: boolean }; - error?: { message?: string }; - }; - expect(response.id).toBe(41); - expect(response.error).toBeUndefined(); - expect(response.result?.ok).toBe(true); - - cdp.close(); - ext2.close(); - }); - - it("closes CDP clients after reconnect grace when extension stays disconnected", async () => { - process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "150"; - - const { port, ext } = await startRelayWithExtension(); - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - - ext.close(); - await waitForClose(cdp, 2_000); - }); - - it("stops advertising websocket endpoint after reconnect grace expires", async () => { - process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "120"; - - const { ext } = await startRelayWithExtension(); - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-grace-expire", - targetInfo: { - targetId: "t-grace-expire", - type: "page", - title: "Grace expire", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.some((entry) => entry.id === "t-grace-expire"), - ); - - ext.close(); - await expect - .poll( - async () => { - const version = (await fetch(`${cdpUrl}/json/version`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as { webSocketDebuggerUrl?: string }; - return version.webSocketDebuggerUrl === undefined; - }, - { timeout: 800, interval: 20 }, - ) - .toBe(true); - }); - - it("accepts extension websocket access with relay token query param", async () => { - const sharedUrl = await ensureSharedRelayServer(); - const sharedPort = new URL(sharedUrl).port; - - const token = relayAuthHeaders(`ws://127.0.0.1:${sharedPort}/extension`)[ - "x-openclaw-relay-token" - ]; - expect(token).toBeTruthy(); - const ext = new WebSocket( - `ws://127.0.0.1:${sharedPort}/extension?token=${encodeURIComponent(String(token))}`, - ); - await waitForOpen(ext); - ext.close(); - }); - - it("accepts /json endpoints with relay token query param", async () => { - const sharedUrl = await ensureSharedRelayServer(); - - const token = relayAuthHeaders(sharedUrl)["x-openclaw-relay-token"]; - expect(token).toBeTruthy(); - const versionRes = await fetch( - `${sharedUrl}/json/version?token=${encodeURIComponent(String(token))}`, - ); - expect(versionRes.status).toBe(200); - }); - - it("accepts raw gateway token for relay auth compatibility", async () => { - const sharedUrl = await ensureSharedRelayServer(); - const sharedPort = new URL(sharedUrl).port; - - const versionRes = await fetch(`${sharedUrl}/json/version`, { - headers: { "x-openclaw-relay-token": TEST_GATEWAY_TOKEN }, - }); - expect(versionRes.status).toBe(200); - - const ext = new WebSocket( - `ws://127.0.0.1:${sharedPort}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`, - ); - await waitForOpen(ext); - ext.close(); - }); - - it( - "tracks attached page targets and exposes them via CDP + /json/list", - async () => { - const { port, ext } = await startRelayWithExtension(); - - // Simulate a tab attach coming from the extension. - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-1", - targetInfo: { - targetId: "t1", - type: "page", - title: "Example", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - const list = (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ - id?: string; - url?: string; - title?: string; - }>; - expect(list.some((t) => t.id === "t1" && t.url === "https://example.com")).toBe(true); - - // Simulate navigation updating tab metadata. - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.targetInfoChanged", - params: { - targetInfo: { - targetId: "t1", - type: "page", - title: "DER STANDARD", - url: "https://www.derstandard.at/", - }, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ - id?: string; - url?: string; - title?: string; - }>, - (list) => - list.some( - (t) => - t.id === "t1" && - t.url === "https://www.derstandard.at/" && - t.title === "DER STANDARD", - ), - ); - - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - const q = createMessageQueue(cdp); - - cdp.send(JSON.stringify({ id: 1, method: "Target.getTargets" })); - const res1 = JSON.parse(await q.next()) as { id: number; result?: unknown }; - expect(res1.id).toBe(1); - const targetInfos = ( - res1.result as { targetInfos?: Array<{ targetId?: string }> } | undefined - )?.targetInfos; - expect((targetInfos ?? []).some((target) => target.targetId === "t1")).toBe(true); - - cdp.send( - JSON.stringify({ - id: 2, - method: "Target.attachToTarget", - params: { targetId: "t1" }, - }), - ); - const received: Array<{ - id?: number; - method?: string; - result?: unknown; - params?: unknown; - }> = []; - received.push(JSON.parse(await q.next()) as never); - received.push(JSON.parse(await q.next()) as never); - - const res2 = received.find((m) => m.id === 2); - expect(res2?.id).toBe(2); - expect((res2?.result as { sessionId?: string } | undefined)?.sessionId).toBe("cb-tab-1"); - - const evt = received.find((m) => m.method === "Target.attachedToTarget"); - expect(evt?.method).toBe("Target.attachedToTarget"); - expect( - (evt?.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo?.targetId, - ).toBe("t1"); - - cdp.close(); - ext.close(); - }, - RELAY_TEST_TIMEOUT_MS, - ); - - it("removes cached targets from /json/list when targetDestroyed arrives", async () => { - const { ext } = await startRelayWithExtension(); - - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-1", - targetInfo: { - targetId: "t1", - type: "page", - title: "Example", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.some((target) => target.id === "t1"), - ); - - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.targetDestroyed", - params: { targetId: "t1" }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.every((target) => target.id !== "t1"), - ); - ext.close(); - }); - - it("prunes stale cached targets after target-not-found command errors", async () => { - const { port, ext } = await startRelayWithExtension(); - const extQueue = createMessageQueue(ext); - - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-1", - targetInfo: { - targetId: "t1", - type: "page", - title: "Example", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.some((target) => target.id === "t1"), - ); - - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - const cdpQueue = createMessageQueue(cdp); - - cdp.send( - JSON.stringify({ - id: 77, - method: "Runtime.evaluate", - sessionId: "cb-tab-1", - params: { expression: "1+1" }, - }), - ); - - let forwardedId: number | null = null; - for (let attempt = 0; attempt < 6; attempt++) { - const msg = JSON.parse(await extQueue.next()) as { method?: string; id?: number }; - if (msg.method === "forwardCDPCommand" && typeof msg.id === "number") { - forwardedId = msg.id; - break; - } - } - expect(forwardedId).not.toBeNull(); - - ext.send( - JSON.stringify({ - id: forwardedId, - error: "No target with given id", - }), - ); - - let response: { id?: number; error?: { message?: string } } | null = null; - for (let attempt = 0; attempt < 6; attempt++) { - const msg = JSON.parse(await cdpQueue.next()) as { - id?: number; - error?: { message?: string }; - }; - if (msg.id === 77) { - response = msg; - break; - } - } - expect(response?.id).toBe(77); - expect(response?.error?.message ?? "").toContain("No target with given id"); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.every((target) => target.id !== "t1"), - ); - - cdp.close(); - ext.close(); - }); - - it("rebroadcasts attach when a session id is reused for a new target", async () => { - const { port, ext } = await startRelayWithExtension(); - - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), - }); - await waitForOpen(cdp); - const q = createMessageQueue(cdp); - - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "shared-session", - targetInfo: { - targetId: "t1", - type: "page", - title: "First", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - const first = JSON.parse(await q.next()) as { method?: string; params?: unknown }; - expect(first.method).toBe("Target.attachedToTarget"); - expect( - (first.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo?.targetId, - ).toBe("t1"); - - ext.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "shared-session", - targetInfo: { - targetId: "t2", - type: "page", - title: "Second", - url: "https://example.org", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - const received: Array<{ method?: string; params?: unknown }> = []; - received.push(JSON.parse(await q.next()) as never); - received.push(JSON.parse(await q.next()) as never); - - const detached = received.find((m) => m.method === "Target.detachedFromTarget"); - const attached = received.find((m) => m.method === "Target.attachedToTarget"); - expect((detached?.params as { targetId?: string } | undefined)?.targetId).toBe("t1"); - expect( - (attached?.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo - ?.targetId, - ).toBe("t2"); - - cdp.close(); - ext.close(); - }); - - it("reuses an already-bound relay port when another process owns it", async () => { - const port = await getFreePort(); - let probeToken: string | undefined; - const fakeRelay = createServer((req, res) => { - if (req.url?.startsWith("/json/version")) { - const header = req.headers["x-openclaw-relay-token"]; - probeToken = Array.isArray(header) ? header[0] : header; - if (!probeToken) { - res.writeHead(401); - res.end("Unauthorized"); - return; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" })); - return; - } - if (req.url?.startsWith("/extension/status")) { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ connected: false })); - return; - } - res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("OK"); - }); - await new Promise((resolve, reject) => { - fakeRelay.listen(port, "127.0.0.1", () => resolve()); - fakeRelay.once("error", reject); - }); - - try { - cdpUrl = `http://127.0.0.1:${port}`; - const relay = await ensureChromeExtensionRelayServer({ cdpUrl }); - expect(relay.port).toBe(port); - const status = (await fetch(`${cdpUrl}/extension/status`).then((r) => r.json())) as { - connected?: boolean; - }; - expect(status.connected).toBe(false); - expect(probeToken).toBeTruthy(); - expect(probeToken).not.toBe("test-gateway-token"); - } finally { - await new Promise((resolve) => fakeRelay.close(() => resolve())); - } - }); - - it( - "restores tabs after extension reconnects and re-announces", - async () => { - process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "200"; - - const { port, ext: ext1 } = await startRelayWithExtension(); - - ext1.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-10", - targetInfo: { - targetId: "t10", - type: "page", - title: "My Page", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.some((t) => t.id === "t10"), - ); - - // Disconnect extension and wait for grace period cleanup. - const ext1Closed = waitForClose(ext1, 2_000); - ext1.close(); - await ext1Closed; - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.length === 0, - ); - - // Reconnect and re-announce the same tab (simulates reannounceAttachedTabs). - const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext2); - - ext2.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-10", - targetInfo: { - targetId: "t10", - type: "page", - title: "My Page", - url: "https://example.com", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - const list2 = await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string; title?: string }>, - (list) => list.some((t) => t.id === "t10"), - ); - expect(list2.some((t) => t.id === "t10" && t.title === "My Page")).toBe(true); - - ext2.close(); - }, - RELAY_TEST_TIMEOUT_MS, - ); - - it( - "preserves tab across a fast extension reconnect within grace period", - async () => { - process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "2000"; - - const { port, ext: ext1 } = await startRelayWithExtension(); - - ext1.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-20", - targetInfo: { - targetId: "t20", - type: "page", - title: "Persistent", - url: "https://example.org", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>, - (list) => list.some((t) => t.id === "t20"), - ); - - // Disconnect briefly (within grace period). - const ext1Closed = waitForClose(ext1, 2_000); - ext1.close(); - await ext1Closed; - - // Tab should still be listed during grace period. - const listDuringGrace = (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string }>; - expect(listDuringGrace.some((t) => t.id === "t20")).toBe(true); - - // Reconnect within grace and re-announce with updated info. - const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext2); - - ext2.send( - JSON.stringify({ - method: "forwardCDPEvent", - params: { - method: "Target.attachedToTarget", - params: { - sessionId: "cb-tab-20", - targetInfo: { - targetId: "t20", - type: "page", - title: "Persistent Updated", - url: "https://example.org/new", - }, - waitingForDebugger: false, - }, - }, - }), - ); - - const list2 = await waitForListMatch( - async () => - (await fetch(`${cdpUrl}/json/list`, { - headers: relayAuthHeaders(cdpUrl), - }).then((r) => r.json())) as Array<{ id?: string; title?: string; url?: string }>, - (list) => list.some((t) => t.id === "t20" && t.title === "Persistent Updated"), - ); - expect(list2.some((t) => t.id === "t20" && t.url === "https://example.org/new")).toBe(true); - - ext2.close(); - }, - RELAY_TEST_TIMEOUT_MS, - ); - - it("does not swallow EADDRINUSE when occupied port is not an openclaw relay", async () => { - const port = await getFreePort(); - const blocker = createServer((_, res) => { - res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("not-relay"); - }); - await new Promise((resolve, reject) => { - blocker.listen(port, "127.0.0.1", () => resolve()); - blocker.once("error", reject); - }); - const blockedUrl = `http://127.0.0.1:${port}`; - await expect(ensureChromeExtensionRelayServer({ cdpUrl: blockedUrl })).rejects.toThrow( - /EADDRINUSE/i, - ); - await new Promise((resolve) => blocker.close(() => resolve())); - }); - - it( - "respects bindHost override to bind on a non-loopback address", - async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - const relay = await ensureChromeExtensionRelayServer({ - cdpUrl, - bindHost: "0.0.0.0", - }); - expect(relay.port).toBe(port); - // Verify the server actually bound to 0.0.0.0, not the cdpUrl host. - expect(relay.bindHost).toBe("0.0.0.0"); - - const res = await fetch(`http://127.0.0.1:${port}/`); - expect(res.status).toBe(200); - }, - RELAY_TEST_TIMEOUT_MS, - ); - - it( - "defaults bindHost to cdpUrl host when not specified", - async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - const relay = await ensureChromeExtensionRelayServer({ cdpUrl }); - expect(relay.host).toBe("127.0.0.1"); - expect(relay.bindHost).toBe("127.0.0.1"); - - const res = await fetch(`http://127.0.0.1:${port}/`); - expect(res.status).toBe(200); - }, - RELAY_TEST_TIMEOUT_MS, - ); - - it( - "restarts the relay when bindHost changes for the same port", - async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - - const initial = await ensureChromeExtensionRelayServer({ cdpUrl }); - expect(initial.bindHost).toBe("127.0.0.1"); - - const rebound = await ensureChromeExtensionRelayServer({ - cdpUrl, - bindHost: "0.0.0.0", - }); - expect(rebound.bindHost).toBe("0.0.0.0"); - expect(rebound.port).toBe(port); - }, - RELAY_TEST_TIMEOUT_MS, - ); -}); diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts deleted file mode 100644 index 5a87670605e..00000000000 --- a/src/browser/extension-relay.ts +++ /dev/null @@ -1,1068 +0,0 @@ -import type { IncomingMessage } from "node:http"; -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; -import type { Duplex } from "node:stream"; -import WebSocket, { WebSocketServer } from "ws"; -import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js"; -import { rawDataToString } from "../infra/ws.js"; -import { - probeAuthenticatedOpenClawRelay, - resolveRelayAcceptedTokensForPort, - resolveRelayAuthTokenForPort, -} from "./extension-relay-auth.js"; - -type CdpCommand = { - id: number; - method: string; - params?: unknown; - sessionId?: string; -}; - -type CdpResponse = { - id: number; - result?: unknown; - error?: { message: string }; - sessionId?: string; -}; - -type CdpEvent = { - method: string; - params?: unknown; - sessionId?: string; -}; - -type ExtensionForwardCommandMessage = { - id: number; - method: "forwardCDPCommand"; - params: { method: string; params?: unknown; sessionId?: string }; -}; - -type ExtensionResponseMessage = { - id: number; - result?: unknown; - error?: string; -}; - -type ExtensionForwardEventMessage = { - method: "forwardCDPEvent"; - params: { method: string; params?: unknown; sessionId?: string }; -}; - -type ExtensionPingMessage = { method: "ping" }; -type ExtensionPongMessage = { method: "pong" }; - -type ExtensionMessage = - | ExtensionResponseMessage - | ExtensionForwardEventMessage - | ExtensionPongMessage; - -type TargetInfo = { - targetId: string; - type?: string; - title?: string; - url?: string; - attached?: boolean; -}; - -type AttachedToTargetEvent = { - sessionId: string; - targetInfo: TargetInfo; - waitingForDebugger?: boolean; -}; - -type DetachedFromTargetEvent = { - sessionId: string; - targetId?: string; -}; - -type ConnectedTarget = { - sessionId: string; - targetId: string; - targetInfo: TargetInfo; -}; - -const RELAY_AUTH_HEADER = "x-openclaw-relay-token"; -const DEFAULT_EXTENSION_RECONNECT_GRACE_MS = 20_000; -const DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS = 3_000; - -function headerValue(value: string | string[] | undefined): string | undefined { - if (!value) { - return undefined; - } - if (Array.isArray(value)) { - return value[0]; - } - return value; -} - -function getHeader(req: IncomingMessage, name: string): string | undefined { - return headerValue(req.headers[name.toLowerCase()]); -} - -function getRelayAuthTokenFromRequest(req: IncomingMessage, url?: URL): string | undefined { - const headerToken = getHeader(req, RELAY_AUTH_HEADER)?.trim(); - if (headerToken) { - return headerToken; - } - const queryToken = url?.searchParams.get("token")?.trim(); - if (queryToken) { - return queryToken; - } - return undefined; -} - -export type ChromeExtensionRelayServer = { - host: string; - bindHost: string; - port: number; - baseUrl: string; - cdpWsUrl: string; - extensionConnected: () => boolean; - stop: () => Promise; -}; - -type RelayRuntime = { - server: ChromeExtensionRelayServer; - relayAuthToken: string; -}; - -function parseUrlPort(parsed: URL): number | null { - const port = - parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80; - if (!Number.isFinite(port) || port <= 0 || port > 65535) { - return null; - } - return port; -} - -function parseBaseUrl(raw: string): { - host: string; - port: number; - baseUrl: string; -} { - const parsed = new URL(raw.trim().replace(/\/$/, "")); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error(`extension relay cdpUrl must be http(s), got ${parsed.protocol}`); - } - const host = parsed.hostname; - const port = parseUrlPort(parsed); - if (!port) { - throw new Error(`extension relay cdpUrl has invalid port: ${parsed.port || "(empty)"}`); - } - return { host, port, baseUrl: parsed.toString().replace(/\/$/, "") }; -} - -function text(res: Duplex, status: number, bodyText: string) { - const body = Buffer.from(bodyText); - res.write( - `HTTP/1.1 ${status} ${status === 200 ? "OK" : "ERR"}\r\n` + - "Content-Type: text/plain; charset=utf-8\r\n" + - `Content-Length: ${body.length}\r\n` + - "Connection: close\r\n" + - "\r\n", - ); - res.write(body); - res.end(); -} - -function rejectUpgrade(socket: Duplex, status: number, bodyText: string) { - text(socket, status, bodyText); - try { - socket.destroy(); - } catch { - // ignore - } -} - -function envMsOrDefault(name: string, fallback: number): number { - const raw = process.env[name]; - if (!raw || raw.trim() === "") { - return fallback; - } - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - return fallback; - } - return parsed; -} - -const relayRuntimeByPort = new Map(); -const relayInitByPort = new Map>(); - -function isAddrInUseError(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code?: unknown }).code === "EADDRINUSE" - ); -} - -function relayAuthTokenForUrl(url: string): string | null { - try { - const parsed = new URL(url); - if (!isLoopbackHost(parsed.hostname)) { - return null; - } - const port = parseUrlPort(parsed); - if (!port) { - return null; - } - return relayRuntimeByPort.get(port)?.relayAuthToken ?? null; - } catch { - return null; - } -} - -export function getChromeExtensionRelayAuthHeaders(url: string): Record { - const token = relayAuthTokenForUrl(url); - if (!token) { - return {}; - } - return { [RELAY_AUTH_HEADER]: token }; -} - -export async function ensureChromeExtensionRelayServer(opts: { - cdpUrl: string; - bindHost?: string; -}): Promise { - const info = parseBaseUrl(opts.cdpUrl); - if (!isLoopbackHost(info.host)) { - throw new Error(`extension relay requires loopback cdpUrl host (got ${info.host})`); - } - const bindHost = opts.bindHost ?? info.host; - - const existing = relayRuntimeByPort.get(info.port); - if (existing) { - if (existing.server.bindHost !== bindHost) { - await existing.server.stop(); - } else { - return existing.server; - } - } - - const inFlight = relayInitByPort.get(info.port); - if (inFlight) { - const server = await inFlight; - if (server.bindHost === bindHost) { - return server; - } - await server.stop(); - } - - const extensionReconnectGraceMs = envMsOrDefault( - "OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS", - DEFAULT_EXTENSION_RECONNECT_GRACE_MS, - ); - const extensionCommandReconnectWaitMs = envMsOrDefault( - "OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS", - DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS, - ); - - const initPromise = (async (): Promise => { - const relayAuthToken = await resolveRelayAuthTokenForPort(info.port); - const relayAuthTokens = new Set(await resolveRelayAcceptedTokensForPort(info.port)); - - let extensionWs: WebSocket | null = null; - const cdpClients = new Set(); - const connectedTargets = new Map(); - const extensionConnected = () => extensionWs?.readyState === WebSocket.OPEN; - const hasConnectedTargets = () => connectedTargets.size > 0; - let extensionDisconnectCleanupTimer: NodeJS.Timeout | null = null; - const extensionReconnectWaiters = new Set<(connected: boolean) => void>(); - - const flushExtensionReconnectWaiters = (connected: boolean) => { - if (extensionReconnectWaiters.size === 0) { - return; - } - const waiters = Array.from(extensionReconnectWaiters); - extensionReconnectWaiters.clear(); - for (const waiter of waiters) { - waiter(connected); - } - }; - - const clearExtensionDisconnectCleanupTimer = () => { - if (!extensionDisconnectCleanupTimer) { - return; - } - clearTimeout(extensionDisconnectCleanupTimer); - extensionDisconnectCleanupTimer = null; - }; - - const closeCdpClientsAfterExtensionDisconnect = () => { - connectedTargets.clear(); - for (const client of cdpClients) { - try { - client.close(1011, "extension disconnected"); - } catch { - // ignore - } - } - cdpClients.clear(); - flushExtensionReconnectWaiters(false); - }; - - const scheduleExtensionDisconnectCleanup = () => { - clearExtensionDisconnectCleanupTimer(); - extensionDisconnectCleanupTimer = setTimeout(() => { - extensionDisconnectCleanupTimer = null; - if (extensionConnected()) { - return; - } - closeCdpClientsAfterExtensionDisconnect(); - }, extensionReconnectGraceMs); - }; - - const waitForExtensionReconnect = async (timeoutMs: number): Promise => { - if (extensionConnected()) { - return true; - } - return await new Promise((resolve) => { - let settled = false; - const waiter = (connected: boolean) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - extensionReconnectWaiters.delete(waiter); - resolve(connected); - }; - const timer = setTimeout(() => { - waiter(false); - }, timeoutMs); - extensionReconnectWaiters.add(waiter); - }); - }; - - const pendingExtension = new Map< - number, - { - resolve: (v: unknown) => void; - reject: (e: Error) => void; - timer: NodeJS.Timeout; - } - >(); - let nextExtensionId = 1; - - const sendToExtension = async (payload: ExtensionForwardCommandMessage): Promise => { - const ws = extensionWs; - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error("Chrome extension not connected"); - } - ws.send(JSON.stringify(payload)); - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pendingExtension.delete(payload.id); - reject(new Error(`extension request timeout: ${payload.params.method}`)); - }, 30_000); - pendingExtension.set(payload.id, { resolve, reject, timer }); - }); - }; - - const broadcastToCdpClients = (evt: CdpEvent) => { - const msg = JSON.stringify(evt); - for (const ws of cdpClients) { - if (ws.readyState !== WebSocket.OPEN) { - continue; - } - ws.send(msg); - } - }; - - const sendResponseToCdp = (ws: WebSocket, res: CdpResponse) => { - if (ws.readyState !== WebSocket.OPEN) { - return; - } - ws.send(JSON.stringify(res)); - }; - - const dropConnectedTargetSession = (sessionId: string): ConnectedTarget | undefined => { - const existing = connectedTargets.get(sessionId); - if (!existing) { - return undefined; - } - connectedTargets.delete(sessionId); - return existing; - }; - - const dropConnectedTargetsByTargetId = (targetId: string): ConnectedTarget[] => { - const removed: ConnectedTarget[] = []; - for (const [sessionId, target] of connectedTargets) { - if (target.targetId !== targetId) { - continue; - } - connectedTargets.delete(sessionId); - removed.push(target); - } - return removed; - }; - - const broadcastDetachedTarget = (target: ConnectedTarget, targetId?: string) => { - broadcastToCdpClients({ - method: "Target.detachedFromTarget", - params: { - sessionId: target.sessionId, - targetId: targetId ?? target.targetId, - }, - sessionId: target.sessionId, - }); - }; - - const isMissingTargetError = (err: unknown) => { - const message = (err instanceof Error ? err.message : String(err)).toLowerCase(); - return ( - message.includes("target not found") || - message.includes("no target with given id") || - message.includes("session not found") || - message.includes("cannot find session") - ); - }; - - const pruneStaleTargetsFromCommandFailure = (cmd: CdpCommand, err: unknown) => { - if (!isMissingTargetError(err)) { - return; - } - if (cmd.sessionId) { - const removed = dropConnectedTargetSession(cmd.sessionId); - if (removed) { - broadcastDetachedTarget(removed); - return; - } - } - const params = (cmd.params ?? {}) as { targetId?: unknown }; - const targetId = typeof params.targetId === "string" ? params.targetId : undefined; - if (!targetId) { - return; - } - const removedTargets = dropConnectedTargetsByTargetId(targetId); - for (const removed of removedTargets) { - broadcastDetachedTarget(removed, targetId); - } - }; - - const ensureTargetEventsForClient = (ws: WebSocket, mode: "autoAttach" | "discover") => { - for (const target of connectedTargets.values()) { - if (mode === "autoAttach") { - ws.send( - JSON.stringify({ - method: "Target.attachedToTarget", - params: { - sessionId: target.sessionId, - targetInfo: { ...target.targetInfo, attached: true }, - waitingForDebugger: false, - }, - } satisfies CdpEvent), - ); - } else { - ws.send( - JSON.stringify({ - method: "Target.targetCreated", - params: { targetInfo: { ...target.targetInfo, attached: true } }, - } satisfies CdpEvent), - ); - } - } - }; - - const routeCdpCommand = async (cmd: CdpCommand): Promise => { - switch (cmd.method) { - case "Browser.getVersion": - return { - protocolVersion: "1.3", - product: "Chrome/OpenClaw-Extension-Relay", - revision: "0", - userAgent: "OpenClaw-Extension-Relay", - jsVersion: "V8", - }; - case "Browser.setDownloadBehavior": - return {}; - case "Target.setAutoAttach": - case "Target.setDiscoverTargets": - return {}; - case "Target.getTargets": - return { - targetInfos: Array.from(connectedTargets.values()).map((t) => ({ - ...t.targetInfo, - attached: true, - })), - }; - case "Target.getTargetInfo": { - const params = (cmd.params ?? {}) as { targetId?: string }; - const targetId = typeof params.targetId === "string" ? params.targetId : undefined; - if (targetId) { - for (const t of connectedTargets.values()) { - if (t.targetId === targetId) { - return { targetInfo: t.targetInfo }; - } - } - } - if (cmd.sessionId && connectedTargets.has(cmd.sessionId)) { - const t = connectedTargets.get(cmd.sessionId); - if (t) { - return { targetInfo: t.targetInfo }; - } - } - const first = Array.from(connectedTargets.values())[0]; - return { targetInfo: first?.targetInfo }; - } - case "Target.attachToTarget": { - const params = (cmd.params ?? {}) as { targetId?: string }; - const targetId = typeof params.targetId === "string" ? params.targetId : undefined; - if (!targetId) { - throw new Error("targetId required"); - } - for (const t of connectedTargets.values()) { - if (t.targetId === targetId) { - return { sessionId: t.sessionId }; - } - } - throw new Error("target not found"); - } - default: { - const id = nextExtensionId++; - return await sendToExtension({ - id, - method: "forwardCDPCommand", - params: { - method: cmd.method, - sessionId: cmd.sessionId, - params: cmd.params, - }, - }); - } - } - }; - - const server = createServer((req, res) => { - const url = new URL(req.url ?? "/", info.baseUrl); - const path = url.pathname; - const origin = getHeader(req, "origin"); - const isChromeExtensionOrigin = - typeof origin === "string" && origin.startsWith("chrome-extension://"); - - if (isChromeExtensionOrigin && origin) { - // Let extension pages call relay HTTP endpoints cross-origin. - res.setHeader("Access-Control-Allow-Origin", origin); - res.setHeader("Vary", "Origin"); - } - - // Handle CORS preflight requests from the browser extension. - if (req.method === "OPTIONS") { - if (origin && !isChromeExtensionOrigin) { - res.writeHead(403); - res.end("Forbidden"); - return; - } - const requestedHeaders = (getHeader(req, "access-control-request-headers") ?? "") - .split(",") - .map((header) => header.trim().toLowerCase()) - .filter((header) => header.length > 0); - const allowedHeaders = new Set(["content-type", RELAY_AUTH_HEADER, ...requestedHeaders]); - res.writeHead(204, { - "Access-Control-Allow-Origin": origin ?? "*", - "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS", - "Access-Control-Allow-Headers": Array.from(allowedHeaders).join(", "), - "Access-Control-Max-Age": "86400", - Vary: "Origin, Access-Control-Request-Headers", - }); - res.end(); - return; - } - - if (path.startsWith("/json")) { - const token = getRelayAuthTokenFromRequest(req, url); - if (!token || !relayAuthTokens.has(token)) { - res.writeHead(401); - res.end("Unauthorized"); - return; - } - } - - if (req.method === "HEAD" && path === "/") { - res.writeHead(200); - res.end(); - return; - } - - if (path === "/") { - res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("OK"); - return; - } - - if (path === "/extension/status") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ connected: extensionConnected() })); - return; - } - - const hostHeader = req.headers.host?.trim() || `${info.host}:${info.port}`; - const wsHost = `ws://${hostHeader}`; - const cdpWsUrl = `${wsHost}/cdp`; - - if ( - (path === "/json/version" || path === "/json/version/") && - (req.method === "GET" || req.method === "PUT") - ) { - const payload: Record = { - Browser: "OpenClaw/extension-relay", - "Protocol-Version": "1.3", - }; - // Keep reporting CDP WS while attached targets are cached, so callers can - // reconnect through brief MV3 worker disconnects. - if (extensionConnected() || hasConnectedTargets()) { - payload.webSocketDebuggerUrl = cdpWsUrl; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(payload)); - return; - } - - const listPaths = new Set(["/json", "/json/", "/json/list", "/json/list/"]); - if (listPaths.has(path) && (req.method === "GET" || req.method === "PUT")) { - const list = Array.from(connectedTargets.values()).map((t) => ({ - id: t.targetId, - type: t.targetInfo.type ?? "page", - title: t.targetInfo.title ?? "", - description: t.targetInfo.title ?? "", - url: t.targetInfo.url ?? "", - webSocketDebuggerUrl: cdpWsUrl, - devtoolsFrontendUrl: `/devtools/inspector.html?ws=${cdpWsUrl.replace("ws://", "")}`, - })); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(list)); - return; - } - - const handleTargetActionRoute = ( - match: RegExpMatchArray | null, - cdpMethod: "Target.activateTarget" | "Target.closeTarget", - ): boolean => { - if (!match || (req.method !== "GET" && req.method !== "PUT")) { - return false; - } - let targetId = ""; - try { - targetId = decodeURIComponent(match[1] ?? "").trim(); - } catch { - res.writeHead(400); - res.end("invalid targetId encoding"); - return true; - } - if (!targetId) { - res.writeHead(400); - res.end("targetId required"); - return true; - } - void (async () => { - try { - await sendToExtension({ - id: nextExtensionId++, - method: "forwardCDPCommand", - params: { method: cdpMethod, params: { targetId } }, - }); - } catch { - // ignore - } - })(); - res.writeHead(200); - res.end("OK"); - return true; - }; - - if ( - handleTargetActionRoute(path.match(/^\/json\/activate\/(.+)$/), "Target.activateTarget") - ) { - return; - } - if (handleTargetActionRoute(path.match(/^\/json\/close\/(.+)$/), "Target.closeTarget")) { - return; - } - - res.writeHead(404); - res.end("not found"); - }); - - const wssExtension = new WebSocketServer({ noServer: true }); - const wssCdp = new WebSocketServer({ noServer: true }); - - server.on("upgrade", (req, socket, head) => { - const url = new URL(req.url ?? "/", info.baseUrl); - const pathname = url.pathname; - const remote = req.socket.remoteAddress; - - // When bindHost is explicitly non-loopback (e.g. 0.0.0.0 for WSL2), - // allow non-loopback connections; otherwise enforce loopback-only. - if (!isLoopbackAddress(remote) && isLoopbackHost(bindHost)) { - rejectUpgrade(socket, 403, "Forbidden"); - return; - } - - const origin = headerValue(req.headers.origin); - if (origin && !origin.startsWith("chrome-extension://")) { - rejectUpgrade(socket, 403, "Forbidden: invalid origin"); - return; - } - - if (pathname === "/extension") { - const token = getRelayAuthTokenFromRequest(req, url); - if (!token || !relayAuthTokens.has(token)) { - rejectUpgrade(socket, 401, "Unauthorized"); - return; - } - // MV3 worker reconnect races can leave a stale non-OPEN socket reference. - if (extensionWs && extensionWs.readyState !== WebSocket.OPEN) { - try { - extensionWs.terminate(); - } catch { - // ignore - } - extensionWs = null; - } - if (extensionConnected()) { - rejectUpgrade(socket, 409, "Extension already connected"); - return; - } - wssExtension.handleUpgrade(req, socket, head, (ws) => { - wssExtension.emit("connection", ws, req); - }); - return; - } - - if (pathname === "/cdp") { - const token = getRelayAuthTokenFromRequest(req, url); - if (!token || !relayAuthTokens.has(token)) { - rejectUpgrade(socket, 401, "Unauthorized"); - return; - } - // Allow CDP clients to connect even during brief extension worker drops. - // Individual commands already wait briefly for extension reconnect. - wssCdp.handleUpgrade(req, socket, head, (ws) => { - wssCdp.emit("connection", ws, req); - }); - return; - } - - rejectUpgrade(socket, 404, "Not Found"); - }); - - wssExtension.on("connection", (ws) => { - extensionWs = ws; - clearExtensionDisconnectCleanupTimer(); - flushExtensionReconnectWaiters(true); - - const ping = setInterval(() => { - if (ws.readyState !== WebSocket.OPEN) { - return; - } - ws.send(JSON.stringify({ method: "ping" } satisfies ExtensionPingMessage)); - }, 5000); - - ws.on("message", (data) => { - if (extensionWs !== ws) { - return; - } - let parsed: ExtensionMessage | null = null; - try { - parsed = JSON.parse(rawDataToString(data)) as ExtensionMessage; - } catch { - return; - } - - if ( - parsed && - typeof parsed === "object" && - "id" in parsed && - typeof parsed.id === "number" - ) { - const pending = pendingExtension.get(parsed.id); - if (!pending) { - return; - } - pendingExtension.delete(parsed.id); - clearTimeout(pending.timer); - if ("error" in parsed && typeof parsed.error === "string" && parsed.error.trim()) { - pending.reject(new Error(parsed.error)); - } else { - pending.resolve(parsed.result); - } - return; - } - - if (parsed && typeof parsed === "object" && "method" in parsed) { - if ((parsed as ExtensionPongMessage).method === "pong") { - return; - } - if ((parsed as ExtensionForwardEventMessage).method !== "forwardCDPEvent") { - return; - } - const evt = parsed as ExtensionForwardEventMessage; - const method = evt.params?.method; - const params = evt.params?.params; - const sessionId = evt.params?.sessionId; - if (!method || typeof method !== "string") { - return; - } - - if (method === "Target.attachedToTarget") { - const attached = (params ?? {}) as AttachedToTargetEvent; - const targetType = attached?.targetInfo?.type ?? "page"; - if (targetType !== "page") { - return; - } - if (attached?.sessionId && attached?.targetInfo?.targetId) { - const prev = connectedTargets.get(attached.sessionId); - const nextTargetId = attached.targetInfo.targetId; - const prevTargetId = prev?.targetId; - const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId); - connectedTargets.set(attached.sessionId, { - sessionId: attached.sessionId, - targetId: nextTargetId, - targetInfo: attached.targetInfo, - }); - if (changedTarget && prevTargetId) { - broadcastToCdpClients({ - method: "Target.detachedFromTarget", - params: { sessionId: attached.sessionId, targetId: prevTargetId }, - sessionId: attached.sessionId, - }); - } - if (!prev || changedTarget) { - broadcastToCdpClients({ method, params, sessionId }); - } - return; - } - } - - if (method === "Target.detachedFromTarget") { - const detached = (params ?? {}) as DetachedFromTargetEvent; - if (detached?.sessionId) { - dropConnectedTargetSession(detached.sessionId); - } else if (detached?.targetId) { - dropConnectedTargetsByTargetId(detached.targetId); - } - broadcastToCdpClients({ method, params, sessionId }); - return; - } - - if (method === "Target.targetDestroyed" || method === "Target.targetCrashed") { - const targetEvent = (params ?? {}) as { targetId?: string }; - if (targetEvent.targetId) { - dropConnectedTargetsByTargetId(targetEvent.targetId); - } - broadcastToCdpClients({ method, params, sessionId }); - return; - } - - // Keep cached tab metadata fresh for /json/list. - // After navigation, Chrome updates URL/title via Target.targetInfoChanged. - if (method === "Target.targetInfoChanged") { - const changed = (params ?? {}) as { targetInfo?: { targetId?: string; type?: string } }; - const targetInfo = changed?.targetInfo; - const targetId = targetInfo?.targetId; - if (targetId && (targetInfo?.type ?? "page") === "page") { - for (const [sid, target] of connectedTargets) { - if (target.targetId !== targetId) { - continue; - } - connectedTargets.set(sid, { - ...target, - targetInfo: { ...target.targetInfo, ...(targetInfo as object) }, - }); - } - } - } - - broadcastToCdpClients({ method, params, sessionId }); - } - }); - - ws.on("close", () => { - clearInterval(ping); - if (extensionWs !== ws) { - return; - } - extensionWs = null; - for (const [, pending] of pendingExtension) { - clearTimeout(pending.timer); - pending.reject(new Error("extension disconnected")); - } - pendingExtension.clear(); - scheduleExtensionDisconnectCleanup(); - }); - }); - - wssCdp.on("connection", (ws) => { - cdpClients.add(ws); - - ws.on("message", async (data) => { - let cmd: CdpCommand | null = null; - try { - cmd = JSON.parse(rawDataToString(data)) as CdpCommand; - } catch { - return; - } - if (!cmd || typeof cmd !== "object") { - return; - } - if (typeof cmd.id !== "number" || typeof cmd.method !== "string") { - return; - } - - if (!extensionConnected()) { - const reconnected = await waitForExtensionReconnect(extensionCommandReconnectWaitMs); - if (!reconnected || !extensionConnected()) { - sendResponseToCdp(ws, { - id: cmd.id, - sessionId: cmd.sessionId, - error: { message: "Extension not connected" }, - }); - return; - } - } - - try { - const result = await routeCdpCommand(cmd); - - if (cmd.method === "Target.setAutoAttach" && !cmd.sessionId) { - ensureTargetEventsForClient(ws, "autoAttach"); - } - if (cmd.method === "Target.setDiscoverTargets") { - const discover = (cmd.params ?? {}) as { discover?: boolean }; - if (discover.discover === true) { - ensureTargetEventsForClient(ws, "discover"); - } - } - if (cmd.method === "Target.attachToTarget") { - const params = (cmd.params ?? {}) as { targetId?: string }; - const targetId = typeof params.targetId === "string" ? params.targetId : undefined; - if (targetId) { - const target = Array.from(connectedTargets.values()).find( - (t) => t.targetId === targetId, - ); - if (target) { - ws.send( - JSON.stringify({ - method: "Target.attachedToTarget", - params: { - sessionId: target.sessionId, - targetInfo: { ...target.targetInfo, attached: true }, - waitingForDebugger: false, - }, - } satisfies CdpEvent), - ); - } - } - } - - sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result }); - } catch (err) { - pruneStaleTargetsFromCommandFailure(cmd, err); - sendResponseToCdp(ws, { - id: cmd.id, - sessionId: cmd.sessionId, - error: { message: err instanceof Error ? err.message : String(err) }, - }); - } - }); - - ws.on("close", () => { - cdpClients.delete(ws); - }); - }); - - try { - await new Promise((resolve, reject) => { - server.listen(info.port, bindHost, () => resolve()); - server.once("error", reject); - }); - } catch (err) { - if ( - isAddrInUseError(err) && - (await probeAuthenticatedOpenClawRelay({ - baseUrl: info.baseUrl, - relayAuthHeader: RELAY_AUTH_HEADER, - relayAuthToken, - })) - ) { - const existingRelay: ChromeExtensionRelayServer = { - host: info.host, - bindHost, - port: info.port, - baseUrl: info.baseUrl, - cdpWsUrl: `ws://${info.host}:${info.port}/cdp`, - extensionConnected: () => false, - stop: async () => { - relayRuntimeByPort.delete(info.port); - }, - }; - relayRuntimeByPort.set(info.port, { server: existingRelay, relayAuthToken }); - return existingRelay; - } - throw err; - } - - const addr = server.address() as AddressInfo | null; - const port = addr?.port ?? info.port; - const actualBindHost = addr?.address || bindHost; - const host = info.host; - const baseUrl = `${new URL(info.baseUrl).protocol}//${host}:${port}`; - - const relay: ChromeExtensionRelayServer = { - host, - bindHost: actualBindHost, - port, - baseUrl, - cdpWsUrl: `ws://${host}:${port}/cdp`, - extensionConnected, - stop: async () => { - relayRuntimeByPort.delete(port); - clearExtensionDisconnectCleanupTimer(); - flushExtensionReconnectWaiters(false); - for (const [, pending] of pendingExtension) { - clearTimeout(pending.timer); - pending.reject(new Error("server stopping")); - } - pendingExtension.clear(); - try { - extensionWs?.close(1001, "server stopping"); - } catch { - // ignore - } - for (const ws of cdpClients) { - try { - ws.close(1001, "server stopping"); - } catch { - // ignore - } - } - await new Promise((resolve) => { - server.close(() => resolve()); - }); - wssExtension.close(); - wssCdp.close(); - }, - }; - - relayRuntimeByPort.set(port, { server: relay, relayAuthToken }); - return relay; - })(); - relayInitByPort.set(info.port, initPromise); - try { - return await initPromise; - } finally { - relayInitByPort.delete(info.port); - } -} - -export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }): Promise { - const info = parseBaseUrl(opts.cdpUrl); - const existing = relayRuntimeByPort.get(info.port); - if (!existing) { - return false; - } - await existing.server.stop(); - return true; -} diff --git a/src/browser/profile-capabilities.ts b/src/browser/profile-capabilities.ts index b736a77d943..994894239d1 100644 --- a/src/browser/profile-capabilities.ts +++ b/src/browser/profile-capabilities.ts @@ -1,18 +1,12 @@ import type { ResolvedBrowserProfile } from "./config.js"; -export type BrowserProfileMode = - | "local-managed" - | "local-extension-relay" - | "local-existing-session" - | "remote-cdp"; +export type BrowserProfileMode = "local-managed" | "local-existing-session" | "remote-cdp"; export type BrowserProfileCapabilities = { mode: BrowserProfileMode; isRemote: boolean; /** Profile uses the Chrome DevTools MCP server (existing-session driver). */ usesChromeMcp: boolean; - requiresRelay: boolean; - requiresAttachedTab: boolean; usesPersistentPlaywright: boolean; supportsPerTabWs: boolean; supportsJsonTabEndpoints: boolean; @@ -23,28 +17,11 @@ export type BrowserProfileCapabilities = { export function getBrowserProfileCapabilities( profile: ResolvedBrowserProfile, ): BrowserProfileCapabilities { - if (profile.driver === "extension") { - return { - mode: "local-extension-relay", - isRemote: false, - usesChromeMcp: false, - requiresRelay: true, - requiresAttachedTab: true, - usesPersistentPlaywright: false, - supportsPerTabWs: false, - supportsJsonTabEndpoints: true, - supportsReset: true, - supportsManagedTabLimit: false, - }; - } - if (profile.driver === "existing-session") { return { mode: "local-existing-session", isRemote: false, usesChromeMcp: true, - requiresRelay: false, - requiresAttachedTab: false, usesPersistentPlaywright: false, supportsPerTabWs: false, supportsJsonTabEndpoints: false, @@ -58,8 +35,6 @@ export function getBrowserProfileCapabilities( mode: "remote-cdp", isRemote: true, usesChromeMcp: false, - requiresRelay: false, - requiresAttachedTab: false, usesPersistentPlaywright: true, supportsPerTabWs: false, supportsJsonTabEndpoints: false, @@ -72,8 +47,6 @@ export function getBrowserProfileCapabilities( mode: "local-managed", isRemote: false, usesChromeMcp: false, - requiresRelay: false, - requiresAttachedTab: false, usesPersistentPlaywright: false, supportsPerTabWs: true, supportsJsonTabEndpoints: true, @@ -96,9 +69,6 @@ export function resolveDefaultSnapshotFormat(params: { } const capabilities = getBrowserProfileCapabilities(params.profile); - if (capabilities.mode === "local-extension-relay") { - return "aria"; - } if (capabilities.mode === "local-existing-session") { return "ai"; } @@ -112,16 +82,12 @@ export function shouldUsePlaywrightForScreenshot(params: { ref?: string; element?: string; }): boolean { - const capabilities = getBrowserProfileCapabilities(params.profile); - return ( - capabilities.requiresRelay || !params.wsUrl || Boolean(params.ref) || Boolean(params.element) - ); + return !params.wsUrl || Boolean(params.ref) || Boolean(params.element); } export function shouldUsePlaywrightForAriaSnapshot(params: { profile: ResolvedBrowserProfile; wsUrl?: string; }): boolean { - const capabilities = getBrowserProfileCapabilities(params.profile); - return capabilities.requiresRelay || !params.wsUrl; + return !params.wsUrl; } diff --git a/src/browser/profiles-service.test.ts b/src/browser/profiles-service.test.ts index 13bbdf27c49..b726ad3fbdb 100644 --- a/src/browser/profiles-service.test.ts +++ b/src/browser/profiles-service.test.ts @@ -136,37 +136,6 @@ describe("BrowserProfilesService", () => { ); }); - it("rejects driver=extension with non-loopback cdpUrl", async () => { - const resolved = resolveBrowserConfig({}); - const { ctx } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); - - const service = createBrowserProfilesService(ctx); - - await expect( - service.createProfile({ - name: "chrome-remote", - driver: "extension", - cdpUrl: "http://10.0.0.42:9222", - }), - ).rejects.toThrow(/loopback cdpUrl host/i); - }); - - it("rejects driver=extension without an explicit cdpUrl", async () => { - const resolved = resolveBrowserConfig({}); - const { ctx } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); - - const service = createBrowserProfilesService(ctx); - - await expect( - service.createProfile({ - name: "chrome-extension", - driver: "extension", - }), - ).rejects.toThrow(/requires an explicit loopback cdpUrl/i); - }); - it("creates existing-session profiles as attach-only local entries", async () => { const resolved = resolveBrowserConfig({}); const { ctx, state } = createCtx(resolved); diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index 86321006e98..af747015e45 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -3,7 +3,6 @@ import path from "node:path"; import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; -import { isLoopbackHost } from "../gateway/net.js"; import { resolveOpenClawUserDataDir } from "./chrome.js"; import { parseHttpUrl, resolveProfile } from "./config.js"; import { @@ -27,7 +26,7 @@ export type CreateProfileParams = { name: string; color?: string; cdpUrl?: string; - driver?: "openclaw" | "extension" | "existing-session"; + driver?: "openclaw" | "existing-session"; }; export type CreateProfileResult = { @@ -80,12 +79,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { const createProfile = async (params: CreateProfileParams): Promise => { const name = params.name.trim(); const rawCdpUrl = params.cdpUrl?.trim() || undefined; - const driver = - params.driver === "extension" - ? "extension" - : params.driver === "existing-session" - ? "existing-session" - : undefined; + const driver = params.driver === "existing-session" ? "existing-session" : undefined; if (!isValidProfileName(name)) { throw new BrowserValidationError( @@ -117,18 +111,6 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { } catch (err) { throw new BrowserValidationError(String(err)); } - if (driver === "extension") { - if (!isLoopbackHost(parsed.parsed.hostname)) { - throw new BrowserValidationError( - `driver=extension requires a loopback cdpUrl host, got: ${parsed.parsed.hostname}`, - ); - } - if (parsed.parsed.protocol !== "http:" && parsed.parsed.protocol !== "https:") { - throw new BrowserValidationError( - `driver=extension requires an http(s) cdpUrl, got: ${parsed.parsed.protocol.replace(":", "")}`, - ); - } - } if (driver === "existing-session") { throw new BrowserValidationError( "driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow", @@ -140,9 +122,6 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { color: profileColor, }; } else { - if (driver === "extension") { - throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl"); - } if (driver === "existing-session") { // existing-session uses Chrome MCP auto-connect; no CDP port needed profileConfig = { diff --git a/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts b/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts index 8f64b2bf575..2b3bdb32bd8 100644 --- a/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts +++ b/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts @@ -52,7 +52,7 @@ function createExtensionFallbackBrowserHarness(options?: { } describe("pw-session getPageForTargetId", () => { - it("falls back to the only page when CDP session attachment is blocked (extension relays)", async () => { + it("falls back to the only page when Playwright cannot resolve target ids", async () => { const { browserClose, pages } = createExtensionFallbackBrowserHarness(); const [page] = pages; @@ -94,26 +94,20 @@ describe("pw-session getPageForTargetId", () => { } }); - it("resolves extension-relay pages from /json/list without probing page CDP sessions first", async () => { + it("resolves pages from /json/list when page CDP probing fails", async () => { const { newCDPSession, pages } = createExtensionFallbackBrowserHarness({ urls: ["https://alpha.example", "https://beta.example"], newCDPSessionError: "Target.attachToBrowserTarget: Not allowed", }); const [, pageB] = pages; - const fetchSpy = vi.spyOn(globalThis, "fetch"); - fetchSpy - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ Browser: "OpenClaw/extension-relay" }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => [ - { id: "TARGET_A", url: "https://alpha.example" }, - { id: "TARGET_B", url: "https://beta.example" }, - ], - } as Response); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => [ + { id: "TARGET_A", url: "https://alpha.example" }, + { id: "TARGET_B", url: "https://beta.example" }, + ], + } as Response); try { const resolved = await getPageForTargetId({ @@ -121,7 +115,7 @@ describe("pw-session getPageForTargetId", () => { targetId: "TARGET_B", }); expect(resolved).toBe(pageB); - expect(newCDPSession).not.toHaveBeenCalled(); + expect(newCDPSession).toHaveBeenCalled(); } finally { fetchSpy.mockRestore(); } diff --git a/src/browser/pw-session.page-cdp.test.ts b/src/browser/pw-session.page-cdp.test.ts index 1347cca20a1..c00f8af5a02 100644 --- a/src/browser/pw-session.page-cdp.test.ts +++ b/src/browser/pw-session.page-cdp.test.ts @@ -1,61 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; - -const cdpHelperMocks = vi.hoisted(() => ({ - fetchJson: vi.fn(), - withCdpSocket: vi.fn(), -})); - -const chromeMocks = vi.hoisted(() => ({ - getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1:18792/cdp"), -})); - -vi.mock("./cdp.helpers.js", async () => { - const actual = await vi.importActual("./cdp.helpers.js"); - return { - ...actual, - fetchJson: cdpHelperMocks.fetchJson, - withCdpSocket: cdpHelperMocks.withCdpSocket, - }; -}); - -vi.mock("./chrome.js", () => chromeMocks); - -import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js"; +import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; describe("pw-session page-scoped CDP client", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("uses raw relay /cdp commands for extension endpoints when targetId is known", async () => { - cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" }); - const send = vi.fn(async () => ({ ok: true })); - cdpHelperMocks.withCdpSocket.mockImplementation(async (_wsUrl, fn) => await fn(send)); - const newCDPSession = vi.fn(); - const page = { - context: () => ({ - newCDPSession, - }), - }; - - await withPageScopedCdpClient({ - cdpUrl: "http://127.0.0.1:18792", - page: page as never, - targetId: "tab-1", - fn: async (pageSend) => { - await pageSend("Page.bringToFront", { foo: "bar" }); - }, - }); - - expect(send).toHaveBeenCalledWith("Page.bringToFront", { - foo: "bar", - targetId: "tab-1", - }); - expect(newCDPSession).not.toHaveBeenCalled(); - }); - - it("falls back to Playwright page sessions for non-relay endpoints", async () => { - cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "Chrome/145.0" }); + it("uses Playwright page sessions", async () => { const sessionSend = vi.fn(async () => ({ ok: true })); const sessionDetach = vi.fn(async () => {}); const newCDPSession = vi.fn(async () => ({ @@ -80,15 +31,5 @@ describe("pw-session page-scoped CDP client", () => { expect(newCDPSession).toHaveBeenCalledWith(page); expect(sessionSend).toHaveBeenCalledWith("Emulation.setLocaleOverride", { locale: "en-US" }); expect(sessionDetach).toHaveBeenCalledTimes(1); - expect(cdpHelperMocks.withCdpSocket).not.toHaveBeenCalled(); - }); - - it("caches extension-relay endpoint detection by cdpUrl", async () => { - cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" }); - - await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992")).resolves.toBe(true); - await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992/")).resolves.toBe(true); - - expect(cdpHelperMocks.fetchJson).toHaveBeenCalledTimes(1); }); }); diff --git a/src/browser/pw-session.page-cdp.ts b/src/browser/pw-session.page-cdp.ts index 8c2109293cd..ccfc2ee7f34 100644 --- a/src/browser/pw-session.page-cdp.ts +++ b/src/browser/pw-session.page-cdp.ts @@ -1,44 +1,7 @@ import type { CDPSession, Page } from "playwright-core"; -import { - appendCdpPath, - fetchJson, - normalizeCdpHttpBaseForJsonEndpoints, - withCdpSocket, -} from "./cdp.helpers.js"; -import { getChromeWebSocketUrl } from "./chrome.js"; - -const OPENCLAW_EXTENSION_RELAY_BROWSER = "OpenClaw/extension-relay"; type PageCdpSend = (method: string, params?: Record) => Promise; -const extensionRelayByCdpUrl = new Map(); - -function normalizeCdpUrl(raw: string) { - return raw.replace(/\/$/, ""); -} - -export async function isExtensionRelayCdpEndpoint(cdpUrl: string): Promise { - const normalized = normalizeCdpUrl(cdpUrl); - const cached = extensionRelayByCdpUrl.get(normalized); - if (cached !== undefined) { - return cached; - } - - try { - const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(normalized); - const version = await fetchJson<{ Browser?: string }>( - appendCdpPath(cdpHttpBase, "/json/version"), - 2000, - ); - const isRelay = String(version?.Browser ?? "").trim() === OPENCLAW_EXTENSION_RELAY_BROWSER; - extensionRelayByCdpUrl.set(normalized, isRelay); - return isRelay; - } catch { - extensionRelayByCdpUrl.set(normalized, false); - return false; - } -} - async function withPlaywrightPageCdpSession( page: Page, fn: (session: CDPSession) => Promise, @@ -57,17 +20,6 @@ export async function withPageScopedCdpClient(opts: { targetId?: string; fn: (send: PageCdpSend) => Promise; }): Promise { - const targetId = opts.targetId?.trim(); - if (targetId && (await isExtensionRelayCdpEndpoint(opts.cdpUrl))) { - const wsUrl = await getChromeWebSocketUrl(opts.cdpUrl, 2000); - if (!wsUrl) { - throw new Error("CDP websocket unavailable"); - } - return await withCdpSocket(wsUrl, async (send) => { - return await opts.fn((method, params) => send(method, { ...params, targetId })); - }); - } - return await withPlaywrightPageCdpSession(opts.page, async (session) => { return await opts.fn((method, params) => ( diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index 2e63d190dea..97677557543 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -26,7 +26,7 @@ import { assertBrowserNavigationResultAllowed, withBrowserNavigationPolicy, } from "./navigation-guard.js"; -import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js"; +import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; export type BrowserConsoleMessage = { type: string; @@ -454,21 +454,6 @@ async function findPageByTargetId( cdpUrl?: string, ): Promise { const pages = await getAllPages(browser); - const isExtensionRelay = cdpUrl - ? await isExtensionRelayCdpEndpoint(cdpUrl).catch(() => false) - : false; - if (cdpUrl && isExtensionRelay) { - try { - const matched = await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl); - if (matched) { - return matched; - } - } catch { - // Ignore fetch errors and fall through to best-effort single-page fallback. - } - return pages.length === 1 ? (pages[0] ?? null) : null; - } - let resolvedViaCdp = false; for (const page of pages) { let tid: string | null = null; @@ -522,9 +507,7 @@ export async function getPageForTargetId(opts: { } const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); if (!found) { - // Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget), - // which prevents us from resolving a page's targetId via newCDPSession(). If Playwright - // only exposes a single Page, use it as a best-effort fallback. + // If Playwright only exposes a single Page, use it as a best-effort fallback. if (pages.length === 1) { return first; } diff --git a/src/browser/routes/agent.snapshot.plan.test.ts b/src/browser/routes/agent.snapshot.plan.test.ts index 384e24a1c71..1fa8c54b81e 100644 --- a/src/browser/routes/agent.snapshot.plan.test.ts +++ b/src/browser/routes/agent.snapshot.plan.test.ts @@ -3,15 +3,15 @@ import { resolveBrowserConfig, resolveProfile } from "../config.js"; import { resolveSnapshotPlan } from "./agent.snapshot.plan.js"; describe("resolveSnapshotPlan", () => { - it("defaults extension relay snapshots to aria when format is omitted", () => { + it("defaults existing-session snapshots to ai when format is omitted", () => { const resolved = resolveBrowserConfig({ profiles: { - relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" }, + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, }, }); - const profile = resolveProfile(resolved, "relay"); + const profile = resolveProfile(resolved, "user"); expect(profile).toBeTruthy(); - expect(profile?.driver).toBe("extension"); + expect(profile?.driver).toBe("existing-session"); const plan = resolveSnapshotPlan({ profile: profile as NonNullable, @@ -19,7 +19,7 @@ describe("resolveSnapshotPlan", () => { hasPlaywright: true, }); - expect(plan.format).toBe("aria"); + expect(plan.format).toBe("ai"); }); it("keeps ai snapshots for managed browsers when Playwright is available", () => { diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index f6123ac4cf0..c4f5db47a59 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -176,15 +176,18 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow const name = toStringOrEmpty((req.body as { name?: unknown })?.name); const color = toStringOrEmpty((req.body as { color?: unknown })?.color); const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl); - const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver) as - | "openclaw" - | "extension" - | "existing-session" - | ""; + const driver = toStringOrEmpty((req.body as { driver?: unknown })?.driver); if (!name) { return jsonError(res, 400, "name is required"); } + if (driver && driver !== "openclaw" && driver !== "clawd" && driver !== "existing-session") { + return jsonError( + res, + 400, + `unsupported profile driver "${driver}"; use "openclaw", "clawd", or "existing-session"`, + ); + } await withProfilesServiceMutation({ res, @@ -195,10 +198,10 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow color: color || undefined, cdpUrl: cdpUrl || undefined, driver: - driver === "extension" - ? "extension" - : driver === "existing-session" - ? "existing-session" + driver === "existing-session" + ? "existing-session" + : driver === "openclaw" || driver === "clawd" + ? "openclaw" : undefined, }), }); diff --git a/src/browser/server-context.availability.ts b/src/browser/server-context.availability.ts index a0281d53d9f..d7d33fd0fde 100644 --- a/src/browser/server-context.availability.ts +++ b/src/browser/server-context.availability.ts @@ -15,11 +15,7 @@ import { stopOpenClawChrome, } from "./chrome.js"; import type { ResolvedBrowserProfile } from "./config.js"; -import { BrowserConfigurationError, BrowserProfileUnavailableError } from "./errors.js"; -import { - ensureChromeExtensionRelayServer, - stopChromeExtensionRelayServer, -} from "./extension-relay.js"; +import { BrowserProfileUnavailableError } from "./errors.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import { CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, @@ -124,9 +120,6 @@ export function createProfileAvailability({ await stopOpenClawChrome(profileState.running).catch(() => {}); setProfileRunning(null); } - if (previousProfile.driver === "extension") { - await stopChromeExtensionRelayServer({ cdpUrl: previousProfile.cdpUrl }).catch(() => false); - } if (getBrowserProfileCapabilities(previousProfile).usesChromeMcp) { await closeChromeMcpSession(previousProfile.name).catch(() => false); } @@ -166,33 +159,9 @@ export function createProfileAvailability({ const current = state(); const remoteCdp = capabilities.isRemote; const attachOnly = profile.attachOnly; - const isExtension = capabilities.requiresRelay; const profileState = getProfileState(); const httpReachable = await isHttpReachable(); - if (isExtension && remoteCdp) { - throw new BrowserConfigurationError( - `Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`, - ); - } - - if (isExtension) { - if (!httpReachable) { - await ensureChromeExtensionRelayServer({ - cdpUrl: profile.cdpUrl, - bindHost: current.resolved.relayBindHost, - }); - if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) { - throw new BrowserProfileUnavailableError( - `Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`, - ); - } - } - // Browser startup should only ensure relay availability. - // Tab attachment is checked when a tab is actually required. - return; - } - if (!httpReachable) { if ((attachOnly || remoteCdp) && opts.onEnsureAttachTarget) { await opts.onEnsureAttachTarget(profile); @@ -267,12 +236,6 @@ export function createProfileAvailability({ const stopped = await closeChromeMcpSession(profile.name); return { stopped }; } - if (capabilities.requiresRelay) { - const stopped = await stopChromeExtensionRelayServer({ - cdpUrl: profile.cdpUrl, - }); - return { stopped }; - } const profileState = getProfileState(); if (!profileState.running) { return { stopped: false }; diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts deleted file mode 100644 index d3760bd460d..00000000000 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; -import type { BrowserServerState } from "./server-context.js"; -import "./server-context.chrome-test-harness.js"; -import { createBrowserRouteContext } from "./server-context.js"; - -function makeBrowserState(): BrowserServerState { - return { - // oxlint-disable-next-line typescript/no-explicit-any - server: null as any, - port: 0, - resolved: { - enabled: true, - controlPort: 18791, - cdpPortRangeStart: 18800, - cdpPortRangeEnd: 18899, - cdpProtocol: "http", - cdpHost: "127.0.0.1", - cdpIsLoopback: true, - evaluateEnabled: false, - remoteCdpTimeoutMs: 1500, - remoteCdpHandshakeTimeoutMs: 3000, - extraArgs: [], - color: "#FF4500", - headless: true, - noSandbox: false, - attachOnly: false, - defaultProfile: "chrome-relay", - profiles: { - "chrome-relay": { - driver: "extension", - cdpUrl: "http://127.0.0.1:18792", - cdpPort: 18792, - color: "#00AA00", - }, - openclaw: { cdpPort: 18800, color: "#FF4500" }, - }, - }, - profiles: new Map(), - }; -} - -function stubChromeJsonList(responses: unknown[]) { - const fetchMock = vi.fn(); - const queue = [...responses]; - - fetchMock.mockImplementation(async (url: unknown) => { - const u = String(url); - if (!u.includes("/json/list")) { - throw new Error(`unexpected fetch: ${u}`); - } - const next = queue.shift(); - if (!next) { - throw new Error("no more responses"); - } - return { - ok: true, - json: async () => next, - } as unknown as Response; - }); - - global.fetch = withFetchPreconnect(fetchMock); - return fetchMock; -} - -describe("browser server-context ensureTabAvailable", () => { - it("sticks to the last selected target when targetId is omitted", async () => { - // 1st call (snapshot): stable ordering A then B (twice) - // 2nd call (act): reversed ordering B then A (twice) - const responses = [ - [ - { id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }, - { id: "B", type: "page", url: "https://b.example", webSocketDebuggerUrl: "ws://x/b" }, - ], - [ - { id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }, - { id: "B", type: "page", url: "https://b.example", webSocketDebuggerUrl: "ws://x/b" }, - ], - [ - { id: "B", type: "page", url: "https://b.example", webSocketDebuggerUrl: "ws://x/b" }, - { id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }, - ], - [ - { id: "B", type: "page", url: "https://b.example", webSocketDebuggerUrl: "ws://x/b" }, - { id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }, - ], - ]; - stubChromeJsonList(responses); - const state = makeBrowserState(); - - const ctx = createBrowserRouteContext({ - getState: () => state, - }); - - const chromeRelay = ctx.forProfile("chrome-relay"); - const first = await chromeRelay.ensureTabAvailable(); - expect(first.targetId).toBe("A"); - const second = await chromeRelay.ensureTabAvailable(); - expect(second.targetId).toBe("A"); - }); - - it("rejects invalid targetId even when only one extension tab remains", async () => { - const responses = [ - [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], - [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], - ]; - stubChromeJsonList(responses); - const state = makeBrowserState(); - - const ctx = createBrowserRouteContext({ getState: () => state }); - const chromeRelay = ctx.forProfile("chrome-relay"); - await expect(chromeRelay.ensureTabAvailable("NOT_A_TAB")).rejects.toThrow(/tab not found/i); - }); - - it("returns a descriptive message when no extension tabs are attached", async () => { - const responses = [[]]; - stubChromeJsonList(responses); - const state = makeBrowserState(); - - const ctx = createBrowserRouteContext({ getState: () => state }); - const chromeRelay = ctx.forProfile("chrome-relay"); - await expect(chromeRelay.ensureTabAvailable()).rejects.toThrow(/no attached Chrome tabs/i); - }); - - it("waits briefly for extension tabs to reappear when a previous target exists", async () => { - vi.useFakeTimers(); - try { - const responses = [ - // First call: select tab A and store lastTargetId. - [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], - [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], - // Second call: transient drop, then the extension re-announces attached tab A. - [], - [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], - [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], - ]; - stubChromeJsonList(responses); - const state = makeBrowserState(); - - const ctx = createBrowserRouteContext({ getState: () => state }); - const chromeRelay = ctx.forProfile("chrome-relay"); - const first = await chromeRelay.ensureTabAvailable(); - expect(first.targetId).toBe("A"); - - const secondPromise = chromeRelay.ensureTabAvailable(); - await vi.advanceTimersByTimeAsync(250); - const second = await secondPromise; - expect(second.targetId).toBe("A"); - } finally { - vi.useRealTimers(); - } - }); - - it("still fails after the extension-tab grace window expires", async () => { - vi.useFakeTimers(); - try { - const responses = [ - [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], - [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], - ...Array.from({ length: 20 }, () => []), - ]; - stubChromeJsonList(responses); - const state = makeBrowserState(); - - const ctx = createBrowserRouteContext({ getState: () => state }); - const chromeRelay = ctx.forProfile("chrome-relay"); - await chromeRelay.ensureTabAvailable(); - - const pending = expect(chromeRelay.ensureTabAvailable()).rejects.toThrow( - /no attached Chrome tabs/i, - ); - await vi.advanceTimersByTimeAsync(3_500); - await pending; - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/src/browser/server-context.reset.test.ts b/src/browser/server-context.reset.test.ts index 7e74ffd3881..9fcbc1c2a7b 100644 --- a/src/browser/server-context.reset.test.ts +++ b/src/browser/server-context.reset.test.ts @@ -4,10 +4,6 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createProfileResetOps } from "./server-context.reset.js"; -const relayMocks = vi.hoisted(() => ({ - stopChromeExtensionRelayServer: vi.fn(async () => true), -})); - const trashMocks = vi.hoisted(() => ({ movePathToTrash: vi.fn(async (from: string) => `${from}.trashed`), })); @@ -16,7 +12,6 @@ const pwAiMocks = vi.hoisted(() => ({ closePlaywrightBrowserConnection: vi.fn(async () => {}), })); -vi.mock("./extension-relay.js", () => relayMocks); vi.mock("./trash.js", () => trashMocks); vi.mock("./pw-ai.js", () => pwAiMocks); @@ -54,23 +49,6 @@ function createStatelessResetOps(profile: Parameters { - it("stops extension relay for extension profiles", async () => { - const ops = createStatelessResetOps({ - ...localOpenClawProfile(), - name: "chrome", - driver: "extension", - }); - - await expect(ops.resetProfile()).resolves.toEqual({ - moved: false, - from: "http://127.0.0.1:18800", - }); - expect(relayMocks.stopChromeExtensionRelayServer).toHaveBeenCalledWith({ - cdpUrl: "http://127.0.0.1:18800", - }); - expect(trashMocks.movePathToTrash).not.toHaveBeenCalled(); - }); - it("rejects remote non-extension profiles", async () => { const ops = createStatelessResetOps({ ...localOpenClawProfile(), diff --git a/src/browser/server-context.reset.ts b/src/browser/server-context.reset.ts index 09bc31cbf38..ea478f56f31 100644 --- a/src/browser/server-context.reset.ts +++ b/src/browser/server-context.reset.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import type { ResolvedBrowserProfile } from "./config.js"; import { BrowserResetUnsupportedError } from "./errors.js"; -import { stopChromeExtensionRelayServer } from "./extension-relay.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import type { ProfileRuntimeState } from "./server-context.types.js"; import { movePathToTrash } from "./trash.js"; @@ -36,10 +35,6 @@ export function createProfileResetOps({ }: ResetDeps): ResetOps { const capabilities = getBrowserProfileCapabilities(profile); const resetProfile = async () => { - if (capabilities.requiresRelay) { - await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {}); - return { moved: false, from: profile.cdpUrl }; - } if (!capabilities.supportsReset) { throw new BrowserResetUnsupportedError( `reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`, diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index f0ce3e25e06..1a744e06b09 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -36,28 +36,9 @@ export function createProfileSelectionOps({ const ensureTabAvailable = async (targetId?: string): Promise => { await ensureBrowserAvailable(); const profileState = getProfileState(); - let tabs1 = await listTabs(); + const tabs1 = await listTabs(); if (tabs1.length === 0) { - if (capabilities.requiresAttachedTab) { - // Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker - // lifecycle, relay restart). If we previously had a target selected, wait briefly for - // the extension to reconnect and re-announce its attached tabs before failing. - if (profileState.lastTargetId?.trim()) { - const deadlineAt = Date.now() + 3_000; - while (tabs1.length === 0 && Date.now() < deadlineAt) { - await new Promise((resolve) => setTimeout(resolve, 200)); - tabs1 = await listTabs(); - } - } - if (tabs1.length === 0) { - throw new BrowserTabNotFoundError( - `tab not found (no attached Chrome tabs for profile "${profile.name}"). ` + - "Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).", - ); - } - } else { - await openTab("about:blank"); - } + await openTab("about:blank"); } const tabs = await listTabs(); diff --git a/src/browser/server-lifecycle.test.ts b/src/browser/server-lifecycle.test.ts index 5ef331f1784..c64e054f96a 100644 --- a/src/browser/server-lifecycle.test.ts +++ b/src/browser/server-lifecycle.test.ts @@ -1,13 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { resolveProfileMock, ensureChromeExtensionRelayServerMock } = vi.hoisted(() => ({ - resolveProfileMock: vi.fn(), - ensureChromeExtensionRelayServerMock: vi.fn(), -})); - -const { stopOpenClawChromeMock, stopChromeExtensionRelayServerMock } = vi.hoisted(() => ({ +const { stopOpenClawChromeMock } = vi.hoisted(() => ({ stopOpenClawChromeMock: vi.fn(async () => {}), - stopChromeExtensionRelayServerMock: vi.fn(async () => true), })); const { createBrowserRouteContextMock, listKnownProfileNamesMock } = vi.hoisted(() => ({ @@ -19,15 +13,6 @@ vi.mock("./chrome.js", () => ({ stopOpenClawChrome: stopOpenClawChromeMock, })); -vi.mock("./config.js", () => ({ - resolveProfile: resolveProfileMock, -})); - -vi.mock("./extension-relay.js", () => ({ - ensureChromeExtensionRelayServer: ensureChromeExtensionRelayServerMock, - stopChromeExtensionRelayServer: stopChromeExtensionRelayServerMock, -})); - vi.mock("./server-context.js", () => ({ createBrowserRouteContext: createBrowserRouteContextMock, listKnownProfileNames: listKnownProfileNamesMock, @@ -36,49 +21,13 @@ vi.mock("./server-context.js", () => ({ import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; describe("ensureExtensionRelayForProfiles", () => { - beforeEach(() => { - resolveProfileMock.mockClear(); - ensureChromeExtensionRelayServerMock.mockClear(); - }); - - it("starts relay only for extension profiles", async () => { - resolveProfileMock.mockImplementation((_resolved: unknown, name: string) => { - if (name === "chrome-relay") { - return { driver: "extension", cdpUrl: "http://127.0.0.1:18888" }; - } - return { driver: "openclaw", cdpUrl: "http://127.0.0.1:18889" }; - }); - ensureChromeExtensionRelayServerMock.mockResolvedValue(undefined); - - await ensureExtensionRelayForProfiles({ - resolved: { - profiles: { - "chrome-relay": {}, - openclaw: {}, - }, - } as never, - onWarn: vi.fn(), - }); - - expect(ensureChromeExtensionRelayServerMock).toHaveBeenCalledTimes(1); - expect(ensureChromeExtensionRelayServerMock).toHaveBeenCalledWith({ - cdpUrl: "http://127.0.0.1:18888", - }); - }); - - it("reports relay startup errors", async () => { - resolveProfileMock.mockReturnValue({ driver: "extension", cdpUrl: "http://127.0.0.1:18888" }); - ensureChromeExtensionRelayServerMock.mockRejectedValue(new Error("boom")); - const onWarn = vi.fn(); - - await ensureExtensionRelayForProfiles({ - resolved: { profiles: { "chrome-relay": {} } } as never, - onWarn, - }); - - expect(onWarn).toHaveBeenCalledWith( - 'Chrome extension relay init failed for profile "chrome-relay": Error: boom', - ); + it("is a no-op after removing the Chrome extension relay path", async () => { + await expect( + ensureExtensionRelayForProfiles({ + resolved: { profiles: {} } as never, + onWarn: vi.fn(), + }), + ).resolves.toBeUndefined(); }); }); @@ -87,14 +36,13 @@ describe("stopKnownBrowserProfiles", () => { createBrowserRouteContextMock.mockClear(); listKnownProfileNamesMock.mockClear(); stopOpenClawChromeMock.mockClear(); - stopChromeExtensionRelayServerMock.mockClear(); }); it("stops all known profiles and ignores per-profile failures", async () => { - listKnownProfileNamesMock.mockReturnValue(["openclaw", "chrome-relay"]); + listKnownProfileNamesMock.mockReturnValue(["openclaw", "user"]); const stopMap: Record> = { openclaw: vi.fn(async () => {}), - "chrome-relay": vi.fn(async () => { + user: vi.fn(async () => { throw new Error("profile stop failed"); }), }; @@ -112,12 +60,12 @@ describe("stopKnownBrowserProfiles", () => { }); expect(stopMap.openclaw).toHaveBeenCalledTimes(1); - expect(stopMap["chrome-relay"]).toHaveBeenCalledTimes(1); + expect(stopMap.user).toHaveBeenCalledTimes(1); expect(onWarn).not.toHaveBeenCalled(); }); it("stops tracked runtime browsers even when the profile no longer resolves", async () => { - listKnownProfileNamesMock.mockReturnValue(["deleted-local", "deleted-extension"]); + listKnownProfileNamesMock.mockReturnValue(["deleted-local"]); createBrowserRouteContextMock.mockReturnValue({ forProfile: vi.fn(() => { throw new Error("profile not found"); @@ -134,18 +82,7 @@ describe("stopKnownBrowserProfiles", () => { }, }; const launchedBrowser = localRuntime.running; - const extensionRuntime = { - profile: { - name: "deleted-extension", - driver: "extension", - cdpUrl: "http://127.0.0.1:19999", - }, - running: null, - }; - const profiles = new Map([ - ["deleted-local", localRuntime], - ["deleted-extension", extensionRuntime], - ]); + const profiles = new Map([["deleted-local", localRuntime]]); const state = { resolved: { profiles: {} }, profiles, @@ -158,9 +95,6 @@ describe("stopKnownBrowserProfiles", () => { expect(stopOpenClawChromeMock).toHaveBeenCalledWith(launchedBrowser); expect(localRuntime.running).toBeNull(); - expect(stopChromeExtensionRelayServerMock).toHaveBeenCalledWith({ - cdpUrl: "http://127.0.0.1:19999", - }); }); it("warns when profile enumeration fails", async () => { diff --git a/src/browser/server-lifecycle.ts b/src/browser/server-lifecycle.ts index 7053d924b6d..1dd322f2bc9 100644 --- a/src/browser/server-lifecycle.ts +++ b/src/browser/server-lifecycle.ts @@ -1,32 +1,18 @@ import { stopOpenClawChrome } from "./chrome.js"; import type { ResolvedBrowserConfig } from "./config.js"; -import { resolveProfile } from "./config.js"; -import { - ensureChromeExtensionRelayServer, - stopChromeExtensionRelayServer, -} from "./extension-relay.js"; import { type BrowserServerState, createBrowserRouteContext, listKnownProfileNames, } from "./server-context.js"; -export async function ensureExtensionRelayForProfiles(params: { +export async function ensureExtensionRelayForProfiles(_params: { resolved: ResolvedBrowserConfig; onWarn: (message: string) => void; }) { - for (const name of Object.keys(params.resolved.profiles)) { - const profile = resolveProfile(params.resolved, name); - if (!profile || profile.driver !== "extension") { - continue; - } - await ensureChromeExtensionRelayServer({ - cdpUrl: profile.cdpUrl, - bindHost: params.resolved.relayBindHost, - }).catch((err) => { - params.onWarn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`); - }); - } + // Intentional no-op: the Chrome extension relay path has been removed. + // runtime-lifecycle still calls this helper, so keep the stub until the next + // breaking cleanup rather than changing the call graph in a patch release. } export async function stopKnownBrowserProfiles(params: { @@ -50,12 +36,6 @@ export async function stopKnownBrowserProfiles(params: { runtime.running = null; continue; } - if (runtime?.profile.driver === "extension") { - await stopChromeExtensionRelayServer({ cdpUrl: runtime.profile.cdpUrl }).catch( - () => false, - ); - continue; - } await ctx.forProfile(name).stopRunningBrowser(); } catch { // ignore diff --git a/src/browser/server.control-server.test-harness.ts b/src/browser/server.control-server.test-harness.ts index 118c83dbb73..57b8d191655 100644 --- a/src/browser/server.control-server.test-harness.ts +++ b/src/browser/server.control-server.test-harness.ts @@ -18,7 +18,7 @@ type HarnessState = { cdpPort?: number; cdpUrl?: string; color: string; - driver?: "openclaw" | "extension" | "existing-session"; + driver?: "openclaw" | "existing-session"; attachOnly?: boolean; } >; diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts index 8d84ef3c7a8..5ad1d5f7bd2 100644 --- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts @@ -116,18 +116,29 @@ describe("profile CRUD endpoints", () => { const createBadRemoteBody = (await createBadRemote.json()) as { error: string }; expect(createBadRemoteBody.error).toContain("cdpUrl"); - const createBadExtension = await realFetch(`${base}/profiles/create`, { + const createClawd = await realFetch(`${base}/profiles/create`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: "badextension", - driver: "extension", - cdpUrl: "http://10.0.0.42:9222", - }), + body: JSON.stringify({ name: "legacyclawd", driver: "clawd" }), }); - expect(createBadExtension.status).toBe(400); - const createBadExtensionBody = (await createBadExtension.json()) as { error: string }; - expect(createBadExtensionBody.error).toContain("loopback cdpUrl host"); + expect(createClawd.status).toBe(200); + const createClawdBody = (await createClawd.json()) as { + profile?: string; + transport?: string; + cdpPort?: number | null; + }; + expect(createClawdBody.profile).toBe("legacyclawd"); + expect(createClawdBody.transport).toBe("cdp"); + expect(createClawdBody.cdpPort).toBeTypeOf("number"); + + const createLegacyDriver = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "legacy", driver: "extension" }), + }); + expect(createLegacyDriver.status).toBe(400); + const createLegacyDriverBody = (await createLegacyDriver.json()) as { error: string }; + expect(createLegacyDriverBody.error).toContain('unsupported profile driver "extension"'); const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, { method: "DELETE", diff --git a/src/cli/browser-cli-extension.test.ts b/src/cli/browser-cli-extension.test.ts deleted file mode 100644 index 1c8c74d8c6e..00000000000 --- a/src/cli/browser-cli-extension.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import path from "node:path"; -import { Command } from "commander"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { withEnvAsync } from "../test-utils/env.js"; - -const copyToClipboard = vi.fn(); -const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), -}; - -type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" }; - -const state = vi.hoisted(() => ({ - entries: new Map(), - counter: 0, -})); - -const abs = (p: string) => path.resolve(p); - -function setFile(p: string, content = "") { - const resolved = abs(p); - state.entries.set(resolved, { kind: "file", content }); - setDir(path.dirname(resolved)); -} - -function setDir(p: string) { - const resolved = abs(p); - if (!state.entries.has(resolved)) { - state.entries.set(resolved, { kind: "dir" }); - } -} - -function copyTree(src: string, dest: string) { - const srcAbs = abs(src); - const destAbs = abs(dest); - const srcPrefix = `${srcAbs}${path.sep}`; - for (const [key, entry] of state.entries.entries()) { - if (key === srcAbs || key.startsWith(srcPrefix)) { - const rel = key === srcAbs ? "" : key.slice(srcPrefix.length); - const next = rel ? path.join(destAbs, rel) : destAbs; - state.entries.set(next, entry); - } - } -} - -vi.mock("node:fs", async (importOriginal) => { - const actual = await importOriginal(); - const pathMod = await import("node:path"); - const absInMock = (p: string) => pathMod.resolve(p); - - const wrapped = { - ...actual, - existsSync: (p: string) => state.entries.has(absInMock(p)), - mkdirSync: (p: string, _opts?: unknown) => { - setDir(p); - }, - writeFileSync: (p: string, content: string) => { - setFile(p, content); - }, - renameSync: (from: string, to: string) => { - const fromAbs = absInMock(from); - const toAbs = absInMock(to); - const entry = state.entries.get(fromAbs); - if (!entry) { - throw new Error(`ENOENT: no such file or directory, rename '${from}' -> '${to}'`); - } - state.entries.delete(fromAbs); - state.entries.set(toAbs, entry); - }, - rmSync: (p: string) => { - const root = absInMock(p); - const prefix = `${root}${pathMod.sep}`; - const keys = Array.from(state.entries.keys()); - for (const key of keys) { - if (key === root || key.startsWith(prefix)) { - state.entries.delete(key); - } - } - }, - mkdtempSync: (prefix: string) => { - const dir = `${prefix}${state.counter++}`; - setDir(dir); - return dir; - }, - promises: { - ...actual.promises, - cp: async (src: string, dest: string, _opts?: unknown) => { - copyTree(src, dest); - }, - }, - }; - - return { ...wrapped, default: wrapped }; -}); - -vi.mock("../infra/clipboard.js", () => ({ - copyToClipboard, -})); - -vi.mock("../runtime.js", () => ({ - defaultRuntime: runtime, -})); - -let resolveBundledExtensionRootDir: typeof import("./browser-cli-extension.js").resolveBundledExtensionRootDir; -let installChromeExtension: typeof import("./browser-cli-extension.js").installChromeExtension; -let registerBrowserExtensionCommands: typeof import("./browser-cli-extension.js").registerBrowserExtensionCommands; - -beforeAll(async () => { - ({ resolveBundledExtensionRootDir, installChromeExtension, registerBrowserExtensionCommands } = - await import("./browser-cli-extension.js")); -}); - -beforeEach(() => { - state.entries.clear(); - state.counter = 0; - copyToClipboard.mockClear(); - copyToClipboard.mockResolvedValue(false); - runtime.log.mockClear(); - runtime.error.mockClear(); - runtime.exit.mockClear(); -}); - -function writeManifest(dir: string) { - setDir(dir); - setFile(path.join(dir, "manifest.json"), JSON.stringify({ manifest_version: 3 })); -} - -describe("bundled extension resolver (fs-mocked)", () => { - it("walks up to find the assets directory", () => { - const root = abs("/tmp/openclaw-ext-root"); - const here = path.join(root, "dist", "cli"); - const assets = path.join(root, "assets", "chrome-extension"); - - writeManifest(assets); - setDir(here); - - expect(resolveBundledExtensionRootDir(here)).toBe(assets); - }); - - it("prefers the nearest assets directory", () => { - const root = abs("/tmp/openclaw-ext-root-nearest"); - const here = path.join(root, "dist", "cli"); - const distAssets = path.join(root, "dist", "assets", "chrome-extension"); - const rootAssets = path.join(root, "assets", "chrome-extension"); - - writeManifest(distAssets); - writeManifest(rootAssets); - setDir(here); - - expect(resolveBundledExtensionRootDir(here)).toBe(distAssets); - }); -}); - -describe("browser extension install (fs-mocked)", () => { - it("installs into the state dir (never node_modules)", async () => { - const tmp = abs("/tmp/openclaw-ext-install"); - const sourceDir = path.join(tmp, "source-ext"); - writeManifest(sourceDir); - setFile(path.join(sourceDir, "test.txt"), "ok"); - - const result = await installChromeExtension({ stateDir: tmp, sourceDir }); - - expect(result.path).toBe(path.join(tmp, "browser", "chrome-extension")); - expect(state.entries.has(abs(path.join(result.path, "manifest.json")))).toBe(true); - expect(state.entries.has(abs(path.join(result.path, "test.txt")))).toBe(true); - expect(result.path.includes("node_modules")).toBe(false); - }); - - it("copies extension path to clipboard", async () => { - const tmp = abs("/tmp/openclaw-ext-path"); - await withEnvAsync({ OPENCLAW_STATE_DIR: tmp }, async () => { - copyToClipboard.mockResolvedValue(true); - - const dir = path.join(tmp, "browser", "chrome-extension"); - writeManifest(dir); - - const program = new Command(); - const browser = program.command("browser").option("--json", "JSON output", false); - registerBrowserExtensionCommands( - browser, - (cmd) => cmd.parent?.opts?.() as { json?: boolean }, - ); - - await program.parseAsync(["browser", "extension", "path"], { from: "user" }); - expect(copyToClipboard).toHaveBeenCalledWith(dir); - }); - }); -}); diff --git a/src/cli/browser-cli-extension.ts b/src/cli/browser-cli-extension.ts deleted file mode 100644 index a04059dfda6..00000000000 --- a/src/cli/browser-cli-extension.ts +++ /dev/null @@ -1,140 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import type { Command } from "commander"; -import { movePathToTrash } from "../browser/trash.js"; -import { resolveStateDir } from "../config/paths.js"; -import { danger, info } from "../globals.js"; -import { copyToClipboard } from "../infra/clipboard.js"; -import { defaultRuntime } from "../runtime.js"; -import { formatDocsLink } from "../terminal/links.js"; -import { theme } from "../terminal/theme.js"; -import { shortenHomePath } from "../utils.js"; -import { formatCliCommand } from "./command-format.js"; - -export function resolveBundledExtensionRootDir( - here = path.dirname(fileURLToPath(import.meta.url)), -) { - let current = here; - while (true) { - const candidate = path.join(current, "assets", "chrome-extension"); - if (hasManifest(candidate)) { - return candidate; - } - const parent = path.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - - return path.resolve(here, "../../assets/chrome-extension"); -} - -function installedExtensionRootDir() { - return path.join(resolveStateDir(), "browser", "chrome-extension"); -} - -function hasManifest(dir: string) { - return fs.existsSync(path.join(dir, "manifest.json")); -} - -export async function installChromeExtension(opts?: { - stateDir?: string; - sourceDir?: string; -}): Promise<{ path: string }> { - const src = opts?.sourceDir ?? resolveBundledExtensionRootDir(); - if (!hasManifest(src)) { - throw new Error("Bundled Chrome extension is missing. Reinstall OpenClaw and try again."); - } - - const stateDir = opts?.stateDir ?? resolveStateDir(); - const dest = path.join(stateDir, "browser", "chrome-extension"); - fs.mkdirSync(path.dirname(dest), { recursive: true }); - - if (fs.existsSync(dest)) { - await movePathToTrash(dest).catch(() => { - const backup = `${dest}.old-${Date.now()}`; - fs.renameSync(dest, backup); - }); - } - - await fs.promises.cp(src, dest, { recursive: true }); - if (!hasManifest(dest)) { - throw new Error("Chrome extension install failed (manifest.json missing). Try again."); - } - - return { path: dest }; -} - -export function registerBrowserExtensionCommands( - browser: Command, - parentOpts: (cmd: Command) => { json?: boolean }, -) { - const ext = browser.command("extension").description("Chrome extension helpers"); - - ext - .command("install") - .description("Install the Chrome extension to a stable local path") - .action(async (_opts, cmd) => { - const parent = parentOpts(cmd); - let installed: { path: string }; - try { - installed = await installChromeExtension(); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - return; - } - - if (parent?.json) { - defaultRuntime.log(JSON.stringify({ ok: true, path: installed.path }, null, 2)); - return; - } - const displayPath = shortenHomePath(installed.path); - defaultRuntime.log(displayPath); - const copied = await copyToClipboard(installed.path).catch(() => false); - defaultRuntime.error( - info( - [ - copied ? "Copied to clipboard." : "Copy to clipboard unavailable.", - "Next:", - `- Chrome → chrome://extensions → enable “Developer mode”`, - `- “Load unpacked” → select: ${displayPath}`, - `- Pin “OpenClaw Browser Relay”, then click it on the tab (badge shows ON)`, - "", - `${theme.muted("Docs:")} ${formatDocsLink("/tools/chrome-extension", "docs.openclaw.ai/tools/chrome-extension")}`, - ].join("\n"), - ), - ); - }); - - ext - .command("path") - .description("Print the path to the installed Chrome extension (load unpacked)") - .action(async (_opts, cmd) => { - const parent = parentOpts(cmd); - const dir = installedExtensionRootDir(); - if (!hasManifest(dir)) { - defaultRuntime.error( - danger( - [ - `Chrome extension is not installed. Run: "${formatCliCommand("openclaw browser extension install")}"`, - `Docs: ${formatDocsLink("/tools/chrome-extension", "docs.openclaw.ai/tools/chrome-extension")}`, - ].join("\n"), - ), - ); - defaultRuntime.exit(1); - } - if (parent?.json) { - defaultRuntime.log(JSON.stringify({ path: dir }, null, 2)); - return; - } - const displayPath = shortenHomePath(dir); - defaultRuntime.log(displayPath); - const copied = await copyToClipboard(dir).catch(() => false); - if (copied) { - defaultRuntime.error(info("Copied to clipboard.")); - } - }); -} diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index ddf207b28f0..e13b7af003a 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -105,14 +105,14 @@ function logBrowserTabs(tabs: BrowserTab[], json?: boolean) { function usesChromeMcpTransport(params: { transport?: BrowserTransport; - driver?: "openclaw" | "extension" | "existing-session"; + driver?: "openclaw" | "existing-session"; }): boolean { return params.transport === "chrome-mcp" || params.driver === "existing-session"; } function formatBrowserConnectionSummary(params: { transport?: BrowserTransport; - driver?: "openclaw" | "extension" | "existing-session"; + driver?: "openclaw" | "existing-session"; isRemote?: boolean; cdpPort?: number | null; cdpUrl?: string | null; @@ -455,10 +455,7 @@ export function registerBrowserManageCommands( .requiredOption("--name ", "Profile name (lowercase, numbers, hyphens)") .option("--color ", "Profile color (hex format, e.g. #0066CC)") .option("--cdp-url ", "CDP URL for remote Chrome (http/https)") - .option( - "--driver ", - "Profile driver (openclaw|extension|existing-session). Default: openclaw", - ) + .option("--driver ", "Profile driver (openclaw|existing-session). Default: openclaw") .action( async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => { const parent = parentOpts(cmd); @@ -472,12 +469,7 @@ export function registerBrowserManageCommands( name: opts.name, color: opts.color, cdpUrl: opts.cdpUrl, - driver: - opts.driver === "extension" - ? "extension" - : opts.driver === "existing-session" - ? "existing-session" - : undefined, + driver: opts.driver === "existing-session" ? "existing-session" : undefined, }, }, { timeoutMs: 10_000 }, @@ -489,11 +481,7 @@ export function registerBrowserManageCommands( defaultRuntime.log( info( `🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${ - opts.driver === "extension" - ? "\n driver: extension" - : opts.driver === "existing-session" - ? "\n driver: existing-session" - : "" + opts.driver === "existing-session" ? "\n driver: existing-session" : "" }`, ), ); diff --git a/src/cli/browser-cli.ts b/src/cli/browser-cli.ts index 085d06cad80..fd4c9a4a8b3 100644 --- a/src/cli/browser-cli.ts +++ b/src/cli/browser-cli.ts @@ -7,7 +7,6 @@ import { registerBrowserActionInputCommands } from "./browser-cli-actions-input. import { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js"; import { registerBrowserDebugCommands } from "./browser-cli-debug.js"; import { browserActionExamples, browserCoreExamples } from "./browser-cli-examples.js"; -import { registerBrowserExtensionCommands } from "./browser-cli-extension.js"; import { registerBrowserInspectCommands } from "./browser-cli-inspect.js"; import { registerBrowserManageCommands } from "./browser-cli-manage.js"; import type { BrowserParentOpts } from "./browser-cli-shared.js"; @@ -46,7 +45,6 @@ export function registerBrowserCli(program: Command) { const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts; registerBrowserManageCommands(browser, parentOpts); - registerBrowserExtensionCommands(browser, parentOpts); registerBrowserInspectCommands(browser, parentOpts); registerBrowserActionInputCommands(browser, parentOpts); registerBrowserActionObserveCommands(browser, parentOpts); diff --git a/src/commands/doctor-browser.test.ts b/src/commands/doctor-browser.test.ts new file mode 100644 index 00000000000..da59fe5ed9a --- /dev/null +++ b/src/commands/doctor-browser.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import { noteChromeMcpBrowserReadiness } from "./doctor-browser.js"; + +describe("doctor browser readiness", () => { + it("does nothing when Chrome MCP is not configured", async () => { + const noteFn = vi.fn(); + await noteChromeMcpBrowserReadiness( + { + browser: { + profiles: { + openclaw: { color: "#FF4500" }, + }, + }, + }, + { + noteFn, + }, + ); + expect(noteFn).not.toHaveBeenCalled(); + }); + + it("warns when Chrome MCP is configured but Chrome is missing", async () => { + const noteFn = vi.fn(); + await noteChromeMcpBrowserReadiness( + { + browser: { + defaultProfile: "user", + }, + }, + { + noteFn, + platform: "darwin", + resolveChromeExecutable: () => null, + }, + ); + + expect(noteFn).toHaveBeenCalledTimes(1); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("Google Chrome was not found"); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("chrome://inspect/#remote-debugging"); + }); + + it("warns when detected Chrome is too old for Chrome MCP", async () => { + const noteFn = vi.fn(); + await noteChromeMcpBrowserReadiness( + { + browser: { + profiles: { + chromeLive: { + driver: "existing-session", + color: "#00AA00", + }, + }, + }, + }, + { + noteFn, + platform: "linux", + resolveChromeExecutable: () => ({ path: "/usr/bin/google-chrome" }), + readVersion: () => "Google Chrome 143.0.7499.4", + }, + ); + + expect(noteFn).toHaveBeenCalledTimes(1); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("too old"); + expect(String(noteFn.mock.calls[0]?.[0])).toContain("Chrome 144+"); + }); + + it("reports the detected Chrome version for existing-session profiles", async () => { + const noteFn = vi.fn(); + await noteChromeMcpBrowserReadiness( + { + browser: { + profiles: { + chromeLive: { + driver: "existing-session", + color: "#00AA00", + }, + }, + }, + }, + { + noteFn, + platform: "win32", + resolveChromeExecutable: () => ({ + path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + }), + readVersion: () => "Google Chrome 144.0.7534.0", + }, + ); + + expect(noteFn).toHaveBeenCalledTimes(1); + expect(String(noteFn.mock.calls[0]?.[0])).toContain( + "Detected Chrome Google Chrome 144.0.7534.0", + ); + }); +}); diff --git a/src/commands/doctor-browser.ts b/src/commands/doctor-browser.ts new file mode 100644 index 00000000000..482e370b052 --- /dev/null +++ b/src/commands/doctor-browser.ts @@ -0,0 +1,108 @@ +import { + parseBrowserMajorVersion, + readBrowserVersion, + resolveGoogleChromeExecutableForPlatform, +} from "../browser/chrome.executables.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { note } from "../terminal/note.js"; + +const CHROME_MCP_MIN_MAJOR = 144; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function collectChromeMcpProfileNames(cfg: OpenClawConfig): string[] { + const browser = asRecord(cfg.browser); + if (!browser) { + return []; + } + + const names = new Set(); + const defaultProfile = + typeof browser.defaultProfile === "string" ? browser.defaultProfile.trim() : ""; + if (defaultProfile === "user") { + names.add("user"); + } + + const profiles = asRecord(browser.profiles); + if (!profiles) { + return [...names]; + } + + for (const [profileName, rawProfile] of Object.entries(profiles)) { + const profile = asRecord(rawProfile); + const driver = typeof profile?.driver === "string" ? profile.driver.trim() : ""; + if (driver === "existing-session") { + names.add(profileName); + } + } + + return [...names].toSorted((a, b) => a.localeCompare(b)); +} + +export async function noteChromeMcpBrowserReadiness( + cfg: OpenClawConfig, + deps?: { + platform?: NodeJS.Platform; + noteFn?: typeof note; + resolveChromeExecutable?: (platform: NodeJS.Platform) => { path: string } | null; + readVersion?: (executablePath: string) => string | null; + }, +) { + const profiles = collectChromeMcpProfileNames(cfg); + if (profiles.length === 0) { + return; + } + + const noteFn = deps?.noteFn ?? note; + const platform = deps?.platform ?? process.platform; + const resolveChromeExecutable = + deps?.resolveChromeExecutable ?? resolveGoogleChromeExecutableForPlatform; + const readVersion = deps?.readVersion ?? readBrowserVersion; + const chrome = resolveChromeExecutable(platform); + const profileLabel = profiles.join(", "); + + if (!chrome) { + noteFn( + [ + `- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`, + "- Google Chrome was not found on this host. OpenClaw does not bundle Chrome.", + `- Install Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on the same host as the Gateway or node.`, + "- In Chrome, enable remote debugging at chrome://inspect/#remote-debugging.", + "- Keep Chrome running and accept the attach consent prompt the first time OpenClaw connects.", + "- Docker, headless, and sandbox browser flows stay on raw CDP; this check only applies to host-local Chrome MCP attach.", + ].join("\n"), + "Browser", + ); + return; + } + + const versionRaw = readVersion(chrome.path); + const major = parseBrowserMajorVersion(versionRaw); + const lines = [ + `- Chrome MCP existing-session is configured for profile(s): ${profileLabel}.`, + `- Chrome path: ${chrome.path}`, + ]; + + if (!versionRaw || major === null) { + lines.push( + `- Could not determine the installed Chrome version. Chrome MCP requires Google Chrome ${CHROME_MCP_MIN_MAJOR}+ on this host.`, + ); + } else if (major < CHROME_MCP_MIN_MAJOR) { + lines.push( + `- Detected Chrome ${versionRaw}, which is too old for Chrome MCP existing-session attach. Upgrade to Chrome ${CHROME_MCP_MIN_MAJOR}+.`, + ); + } else { + lines.push(`- Detected Chrome ${versionRaw}.`); + } + + lines.push("- In Chrome, enable remote debugging at chrome://inspect/#remote-debugging."); + lines.push( + "- Keep Chrome running and accept the attach consent prompt the first time OpenClaw connects.", + ); + + noteFn(lines.join("\n"), "Browser"); +} diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 265c90197e2..a1b204b5990 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -179,6 +179,60 @@ describe("doctor config flow", () => { }); }); + it("migrates legacy browser extension profiles to existing-session on repair", async () => { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + browser: { + relayBindHost: "0.0.0.0", + profiles: { + chromeLive: { + driver: "extension", + color: "#00AA00", + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const browser = (result.cfg as { browser?: Record }).browser ?? {}; + expect(browser.relayBindHost).toBeUndefined(); + expect( + ((browser.profiles as Record)?.chromeLive ?? {}).driver, + ).toBe("existing-session"); + }); + + it("notes legacy browser extension migration changes", async () => { + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + await runDoctorConfigWithInput({ + config: { + browser: { + relayBindHost: "127.0.0.1", + profiles: { + chromeLive: { + driver: "extension", + color: "#00AA00", + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const messages = noteSpy.mock.calls + .filter((call) => call[1] === "Doctor changes") + .map((call) => String(call[0])); + expect( + messages.some((line) => line.includes('browser.profiles.chromeLive.driver "extension"')), + ).toBe(true); + expect(messages.some((line) => line.includes("browser.relayBindHost"))).toBe(true); + } finally { + noteSpy.mockRestore(); + } + }); + it("preserves discord streaming intent while stripping unsupported keys on repair", async () => { const result = await runDoctorConfigWithInput({ repair: true, diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 50c9f38eb40..2d6bfa83a11 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -291,6 +291,67 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { } }; + const normalizeLegacyBrowserProfiles = () => { + const rawBrowser = next.browser; + if (!isRecord(rawBrowser)) { + return; + } + + const browser = structuredClone(rawBrowser); + let browserChanged = false; + + if ("relayBindHost" in browser) { + delete browser.relayBindHost; + browserChanged = true; + changes.push( + "Removed browser.relayBindHost (legacy Chrome extension relay setting; host-local Chrome now uses Chrome MCP existing-session attach).", + ); + } + + const rawProfiles = browser.profiles; + if (!isRecord(rawProfiles)) { + if (!browserChanged) { + return; + } + next = { ...next, browser }; + return; + } + + const profiles = { ...rawProfiles }; + let profilesChanged = false; + for (const [profileName, rawProfile] of Object.entries(rawProfiles)) { + if (!isRecord(rawProfile)) { + continue; + } + const rawDriver = typeof rawProfile.driver === "string" ? rawProfile.driver.trim() : ""; + if (rawDriver !== "extension") { + continue; + } + profiles[profileName] = { + ...rawProfile, + driver: "existing-session", + }; + profilesChanged = true; + changes.push( + `Moved browser.profiles.${profileName}.driver "extension" → "existing-session" (Chrome MCP attach).`, + ); + } + + if (profilesChanged) { + browser.profiles = profiles; + browserChanged = true; + } + + if (!browserChanged) { + return; + } + + next = { + ...next, + browser, + }; + }; + const seedMissingDefaultAccountsFromSingleAccountBase = () => { const channels = next.channels as Record | undefined; if (!channels) { @@ -365,6 +426,7 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { normalizeProvider("slack"); normalizeProvider("discord"); seedMissingDefaultAccountsFromSingleAccountBase(); + normalizeLegacyBrowserProfiles(); const normalizeBrowserSsrFPolicyAlias = () => { const rawBrowser = next.browser; diff --git a/src/commands/doctor.fast-path-mocks.ts b/src/commands/doctor.fast-path-mocks.ts index f05e3d929a7..f9d25da73d8 100644 --- a/src/commands/doctor.fast-path-mocks.ts +++ b/src/commands/doctor.fast-path-mocks.ts @@ -8,6 +8,10 @@ vi.mock("./doctor-bootstrap-size.js", () => ({ noteBootstrapFileSize: vi.fn().mockResolvedValue(undefined), })); +vi.mock("./doctor-browser.js", () => ({ + noteChromeMcpBrowserReadiness: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("./doctor-gateway-daemon-flow.js", () => ({ maybeRepairGatewayDaemon: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index bdde2781ff9..3e4cbebe5d0 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -29,6 +29,7 @@ import { noteAuthProfileHealth, } from "./doctor-auth.js"; import { noteBootstrapFileSize } from "./doctor-bootstrap-size.js"; +import { noteChromeMcpBrowserReadiness } from "./doctor-browser.js"; import { doctorShellCompletion } from "./doctor-completion.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { maybeRepairLegacyCronStore } from "./doctor-cron.js"; @@ -236,6 +237,7 @@ export async function doctorCommand( await noteMacLaunchctlGatewayEnvOverrides(cfg); await noteSecurityWarnings(cfg); + await noteChromeMcpBrowserReadiness(cfg); await noteOpenAIOAuthTlsPrerequisites({ cfg, deep: options.deep === true, diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 7de4e592b23..2680013a717 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -422,7 +422,7 @@ const ENUM_EXPECTATIONS: Record = { "gateway.bind": ['"auto"', '"lan"', '"loopback"', '"custom"', '"tailnet"'], "gateway.auth.mode": ['"none"', '"token"', '"password"', '"trusted-proxy"'], "gateway.tailscale.mode": ['"off"', '"serve"', '"funnel"'], - "browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"extension"'], + "browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"existing-session"'], "discovery.mdns.mode": ['"off"', '"minimal"', '"full"'], "wizard.lastRunMode": ['"local"', '"remote"'], "diagnostics.otel.protocol": ['"http/protobuf"', '"grpc"'], diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 8a71c0e9035..46cce98d193 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -254,8 +254,6 @@ export const FIELD_HELP: Record = { "Starting local CDP port used for auto-allocated browser profile ports. Increase this when host-level port defaults conflict with other local services.", "browser.defaultProfile": "Default browser profile name selected when callers do not explicitly choose a profile. Use a stable low-privilege profile as the default to reduce accidental cross-context state use.", - "browser.relayBindHost": - "Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.", "browser.profiles": "Named browser profile connection map used for explicit routing to CDP ports or URLs with optional metadata. Keep profile names consistent and avoid overlapping endpoint definitions.", "browser.profiles.*.cdpPort": @@ -263,7 +261,7 @@ export const FIELD_HELP: Record = { "browser.profiles.*.cdpUrl": "Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.", "browser.profiles.*.driver": - 'Per-profile browser driver mode: "openclaw" (or legacy "clawd") or "extension" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.', + 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for host-local Chrome MCP attachment.', "browser.profiles.*.attachOnly": "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.", "browser.profiles.*.color": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index c8fb887924b..6843b8f410f 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -120,7 +120,6 @@ export const FIELD_LABELS: Record = { "browser.attachOnly": "Browser Attach-only Mode", "browser.cdpPortRangeStart": "Browser CDP Port Range Start", "browser.defaultProfile": "Browser Default Profile", - "browser.relayBindHost": "Browser Relay Bind Address", "browser.profiles": "Browser Profiles", "browser.profiles.*.cdpPort": "Browser Profile CDP Port", "browser.profiles.*.cdpUrl": "Browser Profile CDP URL", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index 5f8e28a0ebe..b50795fd9d0 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -4,7 +4,7 @@ export type BrowserProfileConfig = { /** CDP URL for this profile (use for remote Chrome). */ cdpUrl?: string; /** Profile driver (default: openclaw). */ - driver?: "openclaw" | "clawd" | "extension" | "existing-session"; + driver?: "openclaw" | "clawd" | "existing-session"; /** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */ attachOnly?: boolean; /** Profile color (hex). Auto-assigned at creation. */ @@ -66,10 +66,4 @@ export type BrowserConfig = { * Example: ["--window-size=1920,1080", "--disable-infobars"] */ extraArgs?: string[]; - /** - * Bind address for the Chrome extension relay server. - * Default: "127.0.0.1". Set to "0.0.0.0" for WSL2 or other environments where - * the relay must be reachable from a different network namespace. - */ - relayBindHost?: string; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 7c9b510080f..345c86b3097 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -360,12 +360,7 @@ export const OpenClawSchema = z cdpPort: z.number().int().min(1).max(65535).optional(), cdpUrl: z.string().optional(), driver: z - .union([ - z.literal("openclaw"), - z.literal("clawd"), - z.literal("extension"), - z.literal("existing-session"), - ]) + .union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")]) .optional(), attachOnly: z.boolean().optional(), color: HexColorSchema, @@ -380,7 +375,6 @@ export const OpenClawSchema = z ) .optional(), extraArgs: z.array(z.string()).optional(), - relayBindHost: z.union([z.string().ipv4(), z.string().ipv6()]).optional(), }) .strict() .optional(), diff --git a/src/node-host/invoke-browser.test.ts b/src/node-host/invoke-browser.test.ts index c1dd0d1df76..7f8c51a2b39 100644 --- a/src/node-host/invoke-browser.test.ts +++ b/src/node-host/invoke-browser.test.ts @@ -70,12 +70,12 @@ describe("runBrowserProxyCommand", () => { JSON.stringify({ method: "GET", path: "/snapshot", - profile: "chrome-relay", + profile: "openclaw", timeoutMs: 5, }), ), ).rejects.toThrow( - /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=chrome-relay; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/, + /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=openclaw; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/, ); }); @@ -150,7 +150,7 @@ describe("runBrowserProxyCommand", () => { JSON.stringify({ method: "POST", path: "/act", - profile: "chrome-relay", + profile: "openclaw", timeoutMs: 50, }), ), From 3c6a49b27ea0c77f50b0183dea4aef4679feb911 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:02:48 -0500 Subject: [PATCH 071/133] feishu: harden media support and align capability docs (#47968) * feishu: harden media support and action surface * feishu: format media action changes * feishu: fix review follow-ups * fix: scope Feishu target aliases to Feishu (#47968) (thanks @Takhoffman) --- CHANGELOG.md | 2 + docs/channels/feishu.md | 28 +- extensions/feishu/src/bot.test.ts | 45 ++ extensions/feishu/src/bot.ts | 8 +- extensions/feishu/src/channel.runtime.ts | 4 +- extensions/feishu/src/channel.test.ts | 474 +++++++++++++++-- extensions/feishu/src/channel.ts | 481 +++++++++++++++--- extensions/feishu/src/chat-schema.ts | 7 +- extensions/feishu/src/chat.test.ts | 34 +- extensions/feishu/src/chat.ts | 68 ++- extensions/feishu/src/directory.test.ts | 106 +++- extensions/feishu/src/directory.ts | 76 +-- extensions/feishu/src/media.test.ts | 75 +++ extensions/feishu/src/media.ts | 137 ++++- extensions/feishu/src/pins.ts | 108 ++++ .../feishu/src/send.reply-fallback.test.ts | 69 +++ extensions/feishu/src/send.test.ts | 97 +++- extensions/feishu/src/send.ts | 105 ++-- src/agents/tools/message-tool.ts | 13 + .../message-action-normalization.test.ts | 41 ++ .../outbound/message-action-normalization.ts | 14 +- ...sage-action-runner.plugin-dispatch.test.ts | 99 ++++ .../outbound/message-action-spec.test.ts | 13 + src/infra/outbound/message-action-spec.ts | 41 +- 24 files changed, 1900 insertions(+), 245 deletions(-) create mode 100644 extensions/feishu/src/pins.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 384fcffc330..a80bae4ced0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ Docs: https://docs.openclaw.ai - Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom. - Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc. - Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw. +- Feishu/media: keep native image, file, audio, and video/media handling aligned across outbound sends, inbound downloads, thread replies, directory/action aliases, and capability docs so unsupported areas are explicit instead of implied. +- Feishu/actions: expand the runtime action surface with message read/edit, explicit thread replies, pinning, and operator-facing chat/member inspection so Feishu can operate more of the workspace directly. - Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. - Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc. - Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults. diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 3768906d940..41882e78264 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -711,7 +711,7 @@ Key options: - ✅ Images - ✅ Files - ✅ Audio -- ✅ Video +- ✅ Video/media - ✅ Stickers ### Send @@ -720,4 +720,28 @@ Key options: - ✅ Images - ✅ Files - ✅ Audio -- ⚠️ Rich text (partial support) +- ✅ Video/media +- ✅ Interactive cards +- ⚠️ Rich text (post-style formatting and cards, not arbitrary Feishu authoring features) + +### Threads and replies + +- ✅ Inline replies +- ✅ Topic-thread replies where Feishu exposes `reply_in_thread` +- ✅ Media replies stay thread-aware when replying to a thread/topic message + +## Runtime action surface + +Feishu currently exposes these runtime actions: + +- `send` +- `read` +- `edit` +- `thread-reply` +- `pin` +- `list-pins` +- `unpin` +- `member-info` +- `channel-info` +- `channel-list` +- `react` and `reactions` when reactions are enabled in config diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 3e14bcdadd5..df787b0106a 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1163,6 +1163,51 @@ describe("handleFeishuMessage command authorization", () => { ); }); + it("falls back to the message payload filename when download metadata omits it", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + mockDownloadMessageResourceFeishu.mockResolvedValueOnce({ + buffer: Buffer.from("video"), + contentType: "video/mp4", + }); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + dmPolicy: "open", + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-sender", + }, + }, + message: { + message_id: "msg-media-payload-name", + chat_id: "oc-dm", + chat_type: "p2p", + message_type: "media", + content: JSON.stringify({ + file_key: "file_media_payload", + image_key: "img_media_thumb", + file_name: "payload-name.mp4", + }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockSaveMediaBuffer).toHaveBeenCalledWith( + expect.any(Buffer), + "video/mp4", + "inbound", + expect.any(Number), + "payload-name.mp4", + ); + }); + it("downloads embedded media tags from post messages as files", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index fc84801b124..728bb9a8ffc 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -551,17 +551,17 @@ function parseMediaKeys( const fileKey = normalizeFeishuExternalKey(parsed.file_key); switch (messageType) { case "image": - return { imageKey }; + return { imageKey, fileName: parsed.file_name }; case "file": return { fileKey, fileName: parsed.file_name }; case "audio": - return { fileKey }; + return { fileKey, fileName: parsed.file_name }; case "video": case "media": // Video/media has both file_key (video) and image_key (thumbnail) - return { fileKey, imageKey }; + return { fileKey, imageKey, fileName: parsed.file_name }; case "sticker": - return { fileKey }; + return { fileKey, fileName: parsed.file_name }; default: return {}; } diff --git a/extensions/feishu/src/channel.runtime.ts b/extensions/feishu/src/channel.runtime.ts index 61f637a94de..0e4d9fc7583 100644 --- a/extensions/feishu/src/channel.runtime.ts +++ b/extensions/feishu/src/channel.runtime.ts @@ -1,5 +1,7 @@ export { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive } from "./directory.js"; export { feishuOutbound } from "./outbound.js"; +export { createPinFeishu, listPinsFeishu, removePinFeishu } from "./pins.js"; export { probeFeishu } from "./probe.js"; export { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js"; -export { sendCardFeishu, sendMessageFeishu } from "./send.js"; +export { getChatInfo, getChatMembers, getFeishuMemberInfo } from "./chat.js"; +export { editMessageFeishu, getMessageFeishu, sendCardFeishu, sendMessageFeishu } from "./send.js"; diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index e7db645be0b..826ca1c26fb 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -1,17 +1,49 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const probeFeishuMock = vi.hoisted(() => vi.fn()); +const createFeishuClientMock = vi.hoisted(() => vi.fn()); +const addReactionFeishuMock = vi.hoisted(() => vi.fn()); const listReactionsFeishuMock = vi.hoisted(() => vi.fn()); +const removeReactionFeishuMock = vi.hoisted(() => vi.fn()); +const sendCardFeishuMock = vi.hoisted(() => vi.fn()); +const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); +const getMessageFeishuMock = vi.hoisted(() => vi.fn()); +const editMessageFeishuMock = vi.hoisted(() => vi.fn()); +const createPinFeishuMock = vi.hoisted(() => vi.fn()); +const listPinsFeishuMock = vi.hoisted(() => vi.fn()); +const removePinFeishuMock = vi.hoisted(() => vi.fn()); +const getChatInfoMock = vi.hoisted(() => vi.fn()); +const getChatMembersMock = vi.hoisted(() => vi.fn()); +const getFeishuMemberInfoMock = vi.hoisted(() => vi.fn()); +const listFeishuDirectoryPeersLiveMock = vi.hoisted(() => vi.fn()); +const listFeishuDirectoryGroupsLiveMock = vi.hoisted(() => vi.fn()); vi.mock("./probe.js", () => ({ probeFeishu: probeFeishuMock, })); -vi.mock("./reactions.js", () => ({ - addReactionFeishu: vi.fn(), +vi.mock("./client.js", () => ({ + createFeishuClient: createFeishuClientMock, +})); + +vi.mock("./channel.runtime.js", () => ({ + addReactionFeishu: addReactionFeishuMock, + createPinFeishu: createPinFeishuMock, + editMessageFeishu: editMessageFeishuMock, + getChatInfo: getChatInfoMock, + getChatMembers: getChatMembersMock, + getFeishuMemberInfo: getFeishuMemberInfoMock, + getMessageFeishu: getMessageFeishuMock, + listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveMock, + listFeishuDirectoryPeersLive: listFeishuDirectoryPeersLiveMock, + listPinsFeishu: listPinsFeishuMock, listReactionsFeishu: listReactionsFeishuMock, - removeReactionFeishu: vi.fn(), + probeFeishu: probeFeishuMock, + removePinFeishu: removePinFeishuMock, + removeReactionFeishu: removeReactionFeishuMock, + sendCardFeishu: sendCardFeishuMock, + sendMessageFeishu: sendMessageFeishuMock, })); import { feishuPlugin } from "./channel.js"; @@ -68,6 +100,28 @@ describe("feishuPlugin actions", () => { }, } as OpenClawConfig; + beforeEach(() => { + vi.clearAllMocks(); + createFeishuClientMock.mockReturnValue({ tag: "client" }); + }); + + it("advertises the expanded Feishu action surface", () => { + expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual([ + "send", + "read", + "edit", + "thread-reply", + "pin", + "list-pins", + "unpin", + "member-info", + "channel-info", + "channel-list", + "react", + "reactions", + ]); + }); + it("does not advertise reactions when disabled via actions config", () => { const disabledCfg = { channels: { @@ -82,41 +136,355 @@ describe("feishuPlugin actions", () => { }, } as OpenClawConfig; - expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([]); + expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([ + "send", + "read", + "edit", + "thread-reply", + "pin", + "list-pins", + "unpin", + "member-info", + "channel-info", + "channel-list", + ]); }); - it("advertises reactions when any enabled configured account allows them", () => { - const cfg = { - channels: { - feishu: { - enabled: true, - defaultAccount: "main", - actions: { - reactions: false, - }, - accounts: { - main: { - appId: "cli_main", - appSecret: "secret_main", - enabled: true, - actions: { - reactions: false, - }, - }, - secondary: { - appId: "cli_secondary", - appSecret: "secret_secondary", - enabled: true, - actions: { - reactions: true, - }, - }, - }, - }, - }, - } as OpenClawConfig; + it("sends text messages", async () => { + sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_sent", chatId: "oc_group_1" }); - expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual(["react", "reactions"]); + const result = await feishuPlugin.actions?.handleAction?.({ + action: "send", + params: { to: "chat:oc_group_1", message: "hello" }, + cfg, + accountId: undefined, + toolContext: {}, + } as never); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith({ + cfg, + to: "chat:oc_group_1", + text: "hello", + accountId: undefined, + replyToMessageId: undefined, + replyInThread: false, + }); + expect(result?.details).toMatchObject({ ok: true, messageId: "om_sent", chatId: "oc_group_1" }); + }); + + it("sends card messages", async () => { + sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" }); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "send", + params: { to: "chat:oc_group_1", card: { schema: "2.0" } }, + cfg, + accountId: undefined, + toolContext: {}, + } as never); + + expect(sendCardFeishuMock).toHaveBeenCalledWith({ + cfg, + to: "chat:oc_group_1", + card: { schema: "2.0" }, + accountId: undefined, + replyToMessageId: undefined, + replyInThread: false, + }); + expect(result?.details).toMatchObject({ ok: true, messageId: "om_card", chatId: "oc_group_1" }); + }); + + it("reads messages", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_1", + content: "hello", + contentType: "text", + }); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "read", + params: { messageId: "om_1" }, + cfg, + accountId: undefined, + } as never); + + expect(getMessageFeishuMock).toHaveBeenCalledWith({ + cfg, + messageId: "om_1", + accountId: undefined, + }); + expect(result?.details).toMatchObject({ + ok: true, + message: expect.objectContaining({ messageId: "om_1", content: "hello" }), + }); + }); + + it("returns an error result when message reads fail", async () => { + getMessageFeishuMock.mockResolvedValueOnce(null); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "read", + params: { messageId: "om_missing" }, + cfg, + accountId: undefined, + } as never); + + expect((result as { isError?: boolean } | undefined)?.isError).toBe(true); + expect(result?.details).toEqual({ + error: "Feishu read failed or message not found: om_missing", + }); + }); + + it("edits messages", async () => { + editMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_2", contentType: "post" }); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "edit", + params: { messageId: "om_2", text: "updated" }, + cfg, + accountId: undefined, + } as never); + + expect(editMessageFeishuMock).toHaveBeenCalledWith({ + cfg, + messageId: "om_2", + text: "updated", + card: undefined, + accountId: undefined, + }); + expect(result?.details).toMatchObject({ ok: true, messageId: "om_2", contentType: "post" }); + }); + + it("sends explicit thread replies with reply_in_thread semantics", async () => { + sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_reply", chatId: "oc_group_1" }); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "thread-reply", + params: { to: "chat:oc_group_1", messageId: "om_parent", text: "reply body" }, + cfg, + accountId: undefined, + toolContext: {}, + } as never); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith({ + cfg, + to: "chat:oc_group_1", + text: "reply body", + accountId: undefined, + replyToMessageId: "om_parent", + replyInThread: true, + }); + expect(result?.details).toMatchObject({ + ok: true, + action: "thread-reply", + messageId: "om_reply", + }); + }); + + it("creates pins", async () => { + createPinFeishuMock.mockResolvedValueOnce({ messageId: "om_pin", chatId: "oc_group_1" }); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "pin", + params: { messageId: "om_pin" }, + cfg, + accountId: undefined, + } as never); + + expect(createPinFeishuMock).toHaveBeenCalledWith({ + cfg, + messageId: "om_pin", + accountId: undefined, + }); + expect(result?.details).toMatchObject({ + ok: true, + pin: expect.objectContaining({ messageId: "om_pin" }), + }); + }); + + it("lists pins", async () => { + listPinsFeishuMock.mockResolvedValueOnce({ + chatId: "oc_group_1", + pins: [{ messageId: "om_pin" }], + hasMore: false, + pageToken: undefined, + }); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "list-pins", + params: { chatId: "oc_group_1" }, + cfg, + accountId: undefined, + toolContext: {}, + } as never); + + expect(listPinsFeishuMock).toHaveBeenCalledWith({ + cfg, + chatId: "oc_group_1", + startTime: undefined, + endTime: undefined, + pageSize: undefined, + pageToken: undefined, + accountId: undefined, + }); + expect(result?.details).toMatchObject({ + ok: true, + pins: [expect.objectContaining({ messageId: "om_pin" })], + }); + }); + + it("removes pins", async () => { + const result = await feishuPlugin.actions?.handleAction?.({ + action: "unpin", + params: { messageId: "om_pin" }, + cfg, + accountId: undefined, + } as never); + + expect(removePinFeishuMock).toHaveBeenCalledWith({ + cfg, + messageId: "om_pin", + accountId: undefined, + }); + expect(result?.details).toMatchObject({ ok: true, messageId: "om_pin" }); + }); + + it("fetches channel info", async () => { + getChatInfoMock.mockResolvedValueOnce({ chat_id: "oc_group_1", name: "Eng" }); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "channel-info", + params: { chatId: "oc_group_1" }, + cfg, + accountId: undefined, + toolContext: {}, + } as never); + + expect(createFeishuClientMock).toHaveBeenCalled(); + expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_group_1"); + expect(result?.details).toMatchObject({ + ok: true, + channel: expect.objectContaining({ chat_id: "oc_group_1", name: "Eng" }), + }); + }); + + it("fetches member lists from a chat", async () => { + getChatMembersMock.mockResolvedValueOnce({ + chat_id: "oc_group_1", + members: [{ member_id: "ou_1", name: "Alice" }], + has_more: false, + }); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "member-info", + params: { chatId: "oc_group_1" }, + cfg, + accountId: undefined, + toolContext: {}, + } as never); + + expect(getChatMembersMock).toHaveBeenCalledWith( + { tag: "client" }, + "oc_group_1", + undefined, + undefined, + "open_id", + ); + expect(result?.details).toMatchObject({ + ok: true, + members: [expect.objectContaining({ member_id: "ou_1", name: "Alice" })], + }); + }); + + it("fetches individual member info", async () => { + getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "ou_1", name: "Alice" }); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "member-info", + params: { memberId: "ou_1" }, + cfg, + accountId: undefined, + toolContext: {}, + } as never); + + expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "ou_1", "open_id"); + expect(result?.details).toMatchObject({ + ok: true, + member: expect.objectContaining({ member_id: "ou_1", name: "Alice" }), + }); + }); + + it("infers user_id lookups from the userId alias", async () => { + getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "u_1", name: "Alice" }); + + await feishuPlugin.actions?.handleAction?.({ + action: "member-info", + params: { userId: "u_1" }, + cfg, + accountId: undefined, + toolContext: {}, + } as never); + + expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "u_1", "user_id"); + }); + + it("honors explicit open_id over alias heuristics", async () => { + getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "u_1", name: "Alice" }); + + await feishuPlugin.actions?.handleAction?.({ + action: "member-info", + params: { userId: "u_1", memberIdType: "open_id" }, + cfg, + accountId: undefined, + toolContext: {}, + } as never); + + expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "u_1", "open_id"); + }); + + it("lists directory-backed peers and groups", async () => { + listFeishuDirectoryGroupsLiveMock.mockResolvedValueOnce([{ kind: "group", id: "oc_group_1" }]); + listFeishuDirectoryPeersLiveMock.mockResolvedValueOnce([{ kind: "user", id: "ou_1" }]); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "channel-list", + params: { query: "eng", limit: 5 }, + cfg, + accountId: undefined, + } as never); + + expect(listFeishuDirectoryGroupsLiveMock).toHaveBeenCalledWith({ + cfg, + query: "eng", + limit: 5, + fallbackToStatic: false, + accountId: undefined, + }); + expect(listFeishuDirectoryPeersLiveMock).toHaveBeenCalledWith({ + cfg, + query: "eng", + limit: 5, + fallbackToStatic: false, + accountId: undefined, + }); + expect(result?.details).toMatchObject({ + ok: true, + groups: [expect.objectContaining({ id: "oc_group_1" })], + peers: [expect.objectContaining({ id: "ou_1" })], + }); + }); + + it("fails channel-list when live discovery fails", async () => { + listFeishuDirectoryGroupsLiveMock.mockRejectedValueOnce(new Error("token expired")); + + await expect( + feishuPlugin.actions?.handleAction?.({ + action: "channel-list", + params: { query: "eng", limit: 5, scope: "groups" }, + cfg, + accountId: undefined, + } as never), + ).rejects.toThrow("token expired"); }); it("requires clearAll=true before removing all bot reactions", async () => { @@ -132,17 +500,6 @@ describe("feishuPlugin actions", () => { ); }); - it("throws for unsupported Feishu send actions without card payload", async () => { - await expect( - feishuPlugin.actions?.handleAction?.({ - action: "send", - params: { to: "chat:oc_group_1", message: "hello" }, - cfg, - accountId: undefined, - } as never), - ).rejects.toThrow('Unsupported Feishu action: "send"'); - }); - it("allows explicit clearAll=true when removing all bot reactions", async () => { listReactionsFeishuMock.mockResolvedValueOnce([ { reactionId: "r1", operatorType: "app" }, @@ -161,6 +518,29 @@ describe("feishuPlugin actions", () => { messageId: "om_msg1", accountId: undefined, }); + expect(removeReactionFeishuMock).toHaveBeenCalledTimes(2); expect(result?.details).toMatchObject({ ok: true, removed: 2 }); }); + + it("fails for missing params on supported actions", async () => { + await expect( + feishuPlugin.actions?.handleAction?.({ + action: "thread-reply", + params: { to: "chat:oc_group_1", message: "reply body" }, + cfg, + accountId: undefined, + } as never), + ).rejects.toThrow("Feishu thread-reply requires messageId."); + }); + + it("fails for unsupported action names", async () => { + await expect( + feishuPlugin.actions?.handleAction?.({ + action: "search", + params: {}, + cfg, + accountId: undefined, + } as never), + ).rejects.toThrow('Unsupported Feishu action: "search"'); + }); }); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 450b1fbe88f..5d47c55e16b 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -21,6 +21,7 @@ import { listEnabledFeishuAccounts, resolveDefaultFeishuAccountId, } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; import { FeishuConfigSchema } from "./config-schema.js"; import { parseFeishuConversationId } from "./conversation-id.js"; import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js"; @@ -167,6 +168,118 @@ function matchFeishuAcpConversation(params: { }; } +function jsonActionResult(details: Record) { + return { + content: [{ type: "text" as const, text: JSON.stringify(details) }], + details, + }; +} + +function readFirstString( + params: Record, + keys: string[], + fallback?: string | null, +): string | undefined { + for (const key of keys) { + const value = params[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + if (typeof fallback === "string" && fallback.trim()) { + return fallback.trim(); + } + return undefined; +} + +function readOptionalNumber(params: Record, keys: string[]): number | undefined { + for (const key of keys) { + const value = params[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + } + return undefined; +} + +function resolveFeishuActionTarget(ctx: { + params: Record; + toolContext?: { currentChannelId?: string } | null; +}): string | undefined { + return readFirstString(ctx.params, ["to", "target"], ctx.toolContext?.currentChannelId); +} + +function resolveFeishuChatId(ctx: { + params: Record; + toolContext?: { currentChannelId?: string } | null; +}): string | undefined { + const raw = readFirstString( + ctx.params, + ["chatId", "chat_id", "channelId", "channel_id", "to", "target"], + ctx.toolContext?.currentChannelId, + ); + if (!raw) { + return undefined; + } + if (/^(user|dm|open_id):/i.test(raw)) { + return undefined; + } + if (/^(chat|group|channel):/i.test(raw)) { + return normalizeFeishuTarget(raw) ?? undefined; + } + return raw; +} + +function resolveFeishuMessageId(params: Record): string | undefined { + return readFirstString(params, ["messageId", "message_id", "replyTo", "reply_to"]); +} + +function resolveFeishuMemberId(params: Record): string | undefined { + return readFirstString(params, [ + "memberId", + "member_id", + "userId", + "user_id", + "openId", + "open_id", + "unionId", + "union_id", + ]); +} + +function resolveFeishuMemberIdType( + params: Record, +): "open_id" | "user_id" | "union_id" { + const raw = readFirstString(params, [ + "memberIdType", + "member_id_type", + "userIdType", + "user_id_type", + ]); + if (raw === "open_id" || raw === "user_id" || raw === "union_id") { + return raw; + } + if ( + readFirstString(params, ["userId", "user_id"]) && + !readFirstString(params, ["openId", "open_id", "unionId", "union_id"]) + ) { + return "user_id"; + } + if ( + readFirstString(params, ["unionId", "union_id"]) && + !readFirstString(params, ["openId", "open_id"]) + ) { + return "union_id"; + } + return "open_id"; +} + export const feishuPlugin: ChannelPlugin = { id: "feishu", meta: { @@ -196,7 +309,8 @@ export const feishuPlugin: ChannelPlugin = { agentPrompt: { messageToolHints: () => [ "- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.", - "- Feishu supports interactive cards for rich messages.", + "- Feishu supports interactive cards plus native image, file, audio, and video/media delivery.", + "- Feishu supports `send`, `read`, `edit`, `thread-reply`, pins, and channel/member lookup, plus reactions when enabled.", ], }, groups: { @@ -284,7 +398,18 @@ export const feishuPlugin: ChannelPlugin = { if (listEnabledFeishuAccounts(cfg).length === 0) { return []; } - const actions = new Set(); + const actions = new Set([ + "send", + "read", + "edit", + "thread-reply", + "pin", + "list-pins", + "unpin", + "member-info", + "channel-info", + "channel-list", + ]); if (areAnyFeishuReactionActionsEnabled(cfg)) { actions.add("react"); actions.add("reactions"); @@ -305,49 +430,303 @@ export const feishuPlugin: ChannelPlugin = { ) { throw new Error("Feishu reactions are disabled via actions.reactions."); } - if (ctx.action === "send" && ctx.params.card) { - const card = ctx.params.card as Record; - const to = - typeof ctx.params.to === "string" - ? ctx.params.to.trim() - : typeof ctx.params.target === "string" - ? ctx.params.target.trim() - : ""; + if (ctx.action === "send" || ctx.action === "thread-reply") { + const to = resolveFeishuActionTarget(ctx); if (!to) { - return { - isError: true, - content: [{ type: "text" as const, text: "Feishu card send requires a target (to)." }], - details: { error: "Feishu card send requires a target (to)." }, - }; + throw new Error(`Feishu ${ctx.action} requires a target (to).`); } const replyToMessageId = - typeof ctx.params.replyTo === "string" - ? ctx.params.replyTo.trim() || undefined + ctx.action === "thread-reply" ? resolveFeishuMessageId(ctx.params) : undefined; + if (ctx.action === "thread-reply" && !replyToMessageId) { + throw new Error("Feishu thread-reply requires messageId."); + } + const card = + ctx.params.card && typeof ctx.params.card === "object" + ? (ctx.params.card as Record) : undefined; - const { sendCardFeishu } = await loadFeishuChannelRuntime(); - const result = await sendCardFeishu({ + const text = readFirstString(ctx.params, ["text", "message"]); + if (!card && !text) { + throw new Error(`Feishu ${ctx.action} requires text/message or card.`); + } + const runtime = await loadFeishuChannelRuntime(); + const result = card + ? await runtime.sendCardFeishu({ + cfg: ctx.cfg, + to, + card, + accountId: ctx.accountId ?? undefined, + replyToMessageId, + replyInThread: ctx.action === "thread-reply", + }) + : await runtime.sendMessageFeishu({ + cfg: ctx.cfg, + to, + text: text!, + accountId: ctx.accountId ?? undefined, + replyToMessageId, + replyInThread: ctx.action === "thread-reply", + }); + return jsonActionResult({ + ok: true, + channel: "feishu", + action: ctx.action, + ...result, + }); + } + + if (ctx.action === "read") { + const messageId = resolveFeishuMessageId(ctx.params); + if (!messageId) { + throw new Error("Feishu read requires messageId."); + } + const { getMessageFeishu } = await loadFeishuChannelRuntime(); + const message = await getMessageFeishu({ cfg: ctx.cfg, - to, + messageId, + accountId: ctx.accountId ?? undefined, + }); + if (!message) { + return { + isError: true, + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: `Feishu read failed or message not found: ${messageId}`, + }), + }, + ], + details: { error: `Feishu read failed or message not found: ${messageId}` }, + }; + } + return jsonActionResult({ ok: true, channel: "feishu", action: "read", message }); + } + + if (ctx.action === "edit") { + const messageId = resolveFeishuMessageId(ctx.params); + if (!messageId) { + throw new Error("Feishu edit requires messageId."); + } + const text = readFirstString(ctx.params, ["text", "message"]); + const card = + ctx.params.card && typeof ctx.params.card === "object" + ? (ctx.params.card as Record) + : undefined; + const { editMessageFeishu } = await loadFeishuChannelRuntime(); + const result = await editMessageFeishu({ + cfg: ctx.cfg, + messageId, + text, card, accountId: ctx.accountId ?? undefined, - replyToMessageId, }); - return { - content: [ - { - type: "text" as const, - text: JSON.stringify({ ok: true, channel: "feishu", ...result }), - }, - ], - details: { ok: true, channel: "feishu", ...result }, - }; + return jsonActionResult({ + ok: true, + channel: "feishu", + action: "edit", + ...result, + }); + } + + if (ctx.action === "pin") { + const messageId = resolveFeishuMessageId(ctx.params); + if (!messageId) { + throw new Error("Feishu pin requires messageId."); + } + const { createPinFeishu } = await loadFeishuChannelRuntime(); + const pin = await createPinFeishu({ + cfg: ctx.cfg, + messageId, + accountId: ctx.accountId ?? undefined, + }); + return jsonActionResult({ ok: true, channel: "feishu", action: "pin", pin }); + } + + if (ctx.action === "unpin") { + const messageId = resolveFeishuMessageId(ctx.params); + if (!messageId) { + throw new Error("Feishu unpin requires messageId."); + } + const { removePinFeishu } = await loadFeishuChannelRuntime(); + await removePinFeishu({ + cfg: ctx.cfg, + messageId, + accountId: ctx.accountId ?? undefined, + }); + return jsonActionResult({ + ok: true, + channel: "feishu", + action: "unpin", + messageId, + }); + } + + if (ctx.action === "list-pins") { + const chatId = resolveFeishuChatId(ctx); + if (!chatId) { + throw new Error("Feishu list-pins requires chatId or channelId."); + } + const { listPinsFeishu } = await loadFeishuChannelRuntime(); + const result = await listPinsFeishu({ + cfg: ctx.cfg, + chatId, + startTime: readFirstString(ctx.params, ["startTime", "start_time"]), + endTime: readFirstString(ctx.params, ["endTime", "end_time"]), + pageSize: readOptionalNumber(ctx.params, ["pageSize", "page_size"]), + pageToken: readFirstString(ctx.params, ["pageToken", "page_token"]), + accountId: ctx.accountId ?? undefined, + }); + return jsonActionResult({ + ok: true, + channel: "feishu", + action: "list-pins", + ...result, + }); + } + + if (ctx.action === "channel-info") { + const chatId = resolveFeishuChatId(ctx); + if (!chatId) { + throw new Error("Feishu channel-info requires chatId or channelId."); + } + const runtime = await loadFeishuChannelRuntime(); + const client = createFeishuClient(account); + const channel = await runtime.getChatInfo(client, chatId); + const includeMembers = ctx.params.includeMembers === true || ctx.params.members === true; + if (!includeMembers) { + return jsonActionResult({ + ok: true, + provider: "feishu", + action: "channel-info", + channel, + }); + } + const members = await runtime.getChatMembers( + client, + chatId, + readOptionalNumber(ctx.params, ["pageSize", "page_size"]), + readFirstString(ctx.params, ["pageToken", "page_token"]), + resolveFeishuMemberIdType(ctx.params), + ); + return jsonActionResult({ + ok: true, + provider: "feishu", + action: "channel-info", + channel, + members, + }); + } + + if (ctx.action === "member-info") { + const runtime = await loadFeishuChannelRuntime(); + const client = createFeishuClient(account); + const memberId = resolveFeishuMemberId(ctx.params); + if (memberId) { + const member = await runtime.getFeishuMemberInfo( + client, + memberId, + resolveFeishuMemberIdType(ctx.params), + ); + return jsonActionResult({ + ok: true, + channel: "feishu", + action: "member-info", + member, + }); + } + const chatId = resolveFeishuChatId(ctx); + if (!chatId) { + throw new Error("Feishu member-info requires memberId or chatId/channelId."); + } + const members = await runtime.getChatMembers( + client, + chatId, + readOptionalNumber(ctx.params, ["pageSize", "page_size"]), + readFirstString(ctx.params, ["pageToken", "page_token"]), + resolveFeishuMemberIdType(ctx.params), + ); + return jsonActionResult({ + ok: true, + channel: "feishu", + action: "member-info", + ...members, + }); + } + + if (ctx.action === "channel-list") { + const runtime = await loadFeishuChannelRuntime(); + const query = readFirstString(ctx.params, ["query"]); + const limit = readOptionalNumber(ctx.params, ["limit"]); + const scope = readFirstString(ctx.params, ["scope", "kind"]) ?? "all"; + if ( + scope === "groups" || + scope === "group" || + scope === "channels" || + scope === "channel" + ) { + const groups = await runtime.listFeishuDirectoryGroupsLive({ + cfg: ctx.cfg, + query, + limit, + fallbackToStatic: false, + accountId: ctx.accountId ?? undefined, + }); + return jsonActionResult({ + ok: true, + channel: "feishu", + action: "channel-list", + groups, + }); + } + if ( + scope === "peers" || + scope === "peer" || + scope === "members" || + scope === "member" || + scope === "users" || + scope === "user" + ) { + const peers = await runtime.listFeishuDirectoryPeersLive({ + cfg: ctx.cfg, + query, + limit, + fallbackToStatic: false, + accountId: ctx.accountId ?? undefined, + }); + return jsonActionResult({ + ok: true, + channel: "feishu", + action: "channel-list", + peers, + }); + } + const [groups, peers] = await Promise.all([ + runtime.listFeishuDirectoryGroupsLive({ + cfg: ctx.cfg, + query, + limit, + fallbackToStatic: false, + accountId: ctx.accountId ?? undefined, + }), + runtime.listFeishuDirectoryPeersLive({ + cfg: ctx.cfg, + query, + limit, + fallbackToStatic: false, + accountId: ctx.accountId ?? undefined, + }), + ]); + return jsonActionResult({ + ok: true, + channel: "feishu", + action: "channel-list", + groups, + peers, + }); } if (ctx.action === "react") { - const messageId = - (typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) || - (typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) || - undefined; + const messageId = resolveFeishuMessageId(ctx.params); if (!messageId) { throw new Error("Feishu reaction requires messageId."); } @@ -367,12 +746,7 @@ export const feishuPlugin: ChannelPlugin = { }); const ownReaction = matches.find((entry) => entry.operatorType === "app"); if (!ownReaction) { - return { - content: [ - { type: "text" as const, text: JSON.stringify({ ok: true, removed: null }) }, - ], - details: { ok: true, removed: null }, - }; + return jsonActionResult({ ok: true, removed: null }); } await removeReactionFeishu({ cfg: ctx.cfg, @@ -380,12 +754,7 @@ export const feishuPlugin: ChannelPlugin = { reactionId: ownReaction.reactionId, accountId: ctx.accountId ?? undefined, }); - return { - content: [ - { type: "text" as const, text: JSON.stringify({ ok: true, removed: emoji }) }, - ], - details: { ok: true, removed: emoji }, - }; + return jsonActionResult({ ok: true, removed: emoji }); } if (!emoji) { if (!clearAll) { @@ -409,10 +778,7 @@ export const feishuPlugin: ChannelPlugin = { }); removed += 1; } - return { - content: [{ type: "text" as const, text: JSON.stringify({ ok: true, removed }) }], - details: { ok: true, removed }, - }; + return jsonActionResult({ ok: true, removed }); } const { addReactionFeishu } = await loadFeishuChannelRuntime(); await addReactionFeishu({ @@ -421,17 +787,11 @@ export const feishuPlugin: ChannelPlugin = { emojiType: emoji, accountId: ctx.accountId ?? undefined, }); - return { - content: [{ type: "text" as const, text: JSON.stringify({ ok: true, added: emoji }) }], - details: { ok: true, added: emoji }, - }; + return jsonActionResult({ ok: true, added: emoji }); } if (ctx.action === "reactions") { - const messageId = - (typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) || - (typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) || - undefined; + const messageId = resolveFeishuMessageId(ctx.params); if (!messageId) { throw new Error("Feishu reactions lookup requires messageId."); } @@ -441,10 +801,7 @@ export const feishuPlugin: ChannelPlugin = { messageId, accountId: ctx.accountId ?? undefined, }); - return { - content: [{ type: "text" as const, text: JSON.stringify({ ok: true, reactions }) }], - details: { ok: true, reactions }, - }; + return jsonActionResult({ ok: true, reactions }); } throw new Error(`Unsupported Feishu action: "${String(ctx.action)}"`); diff --git a/extensions/feishu/src/chat-schema.ts b/extensions/feishu/src/chat-schema.ts index 5f7bdd6a5c7..5460f11dcc9 100644 --- a/extensions/feishu/src/chat-schema.ts +++ b/extensions/feishu/src/chat-schema.ts @@ -1,15 +1,16 @@ import { Type, type Static } from "@sinclair/typebox"; -const CHAT_ACTION_VALUES = ["members", "info"] as const; +const CHAT_ACTION_VALUES = ["members", "info", "member_info"] as const; const MEMBER_ID_TYPE_VALUES = ["open_id", "user_id", "union_id"] as const; export const FeishuChatSchema = Type.Object({ action: Type.Unsafe<(typeof CHAT_ACTION_VALUES)[number]>({ type: "string", enum: [...CHAT_ACTION_VALUES], - description: "Action to run: members | info", + description: "Action to run: members | info | member_info", }), - chat_id: Type.String({ description: "Chat ID (from URL or event payload)" }), + chat_id: Type.Optional(Type.String({ description: "Chat ID (from URL or event payload)" })), + member_id: Type.Optional(Type.String({ description: "Member ID for member_info lookups" })), page_size: Type.Optional(Type.Number({ description: "Page size (1-100, default 50)" })), page_token: Type.Optional(Type.String({ description: "Pagination token" })), member_id_type: Type.Optional( diff --git a/extensions/feishu/src/chat.test.ts b/extensions/feishu/src/chat.test.ts index 9ebf579f962..d06442b12f8 100644 --- a/extensions/feishu/src/chat.test.ts +++ b/extensions/feishu/src/chat.test.ts @@ -2,15 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerFeishuChatTools } from "./chat.js"; const createFeishuClientMock = vi.hoisted(() => vi.fn()); +const chatGetMock = vi.hoisted(() => vi.fn()); +const chatMembersGetMock = vi.hoisted(() => vi.fn()); +const contactUserGetMock = vi.hoisted(() => vi.fn()); vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock, })); describe("registerFeishuChatTools", () => { - const chatGetMock = vi.hoisted(() => vi.fn()); - const chatMembersGetMock = vi.hoisted(() => vi.fn()); - beforeEach(() => { vi.clearAllMocks(); createFeishuClientMock.mockReturnValue({ @@ -18,6 +18,9 @@ describe("registerFeishuChatTools", () => { chat: { get: chatGetMock }, chatMembers: { get: chatMembersGetMock }, }, + contact: { + user: { get: contactUserGetMock }, + }, }); }); @@ -66,6 +69,31 @@ describe("registerFeishuChatTools", () => { members: [expect.objectContaining({ member_id: "ou_1", name: "member1" })], }), ); + + contactUserGetMock.mockResolvedValueOnce({ + code: 0, + data: { + user: { + open_id: "ou_1", + name: "member1", + email: "member1@example.com", + department_ids: ["od_1"], + }, + }, + }); + const memberInfoResult = await tool.execute("tc_3", { + action: "member_info", + member_id: "ou_1", + }); + expect(memberInfoResult.details).toEqual( + expect.objectContaining({ + member_id: "ou_1", + open_id: "ou_1", + name: "member1", + email: "member1@example.com", + department_ids: ["od_1"], + }), + ); }); it("skips registration when chat tool is disabled", () => { diff --git a/extensions/feishu/src/chat.ts b/extensions/feishu/src/chat.ts index df168d579ee..9c62e5648b2 100644 --- a/extensions/feishu/src/chat.ts +++ b/extensions/feishu/src/chat.ts @@ -12,7 +12,7 @@ function json(data: unknown) { }; } -async function getChatInfo(client: Lark.Client, chatId: string) { +export async function getChatInfo(client: Lark.Client, chatId: string) { const res = await client.im.chat.get({ path: { chat_id: chatId } }); if (res.code !== 0) { throw new Error(res.msg); @@ -36,7 +36,7 @@ async function getChatInfo(client: Lark.Client, chatId: string) { }; } -async function getChatMembers( +export async function getChatMembers( client: Lark.Client, chatId: string, pageSize?: number, @@ -71,6 +71,55 @@ async function getChatMembers( }; } +export async function getFeishuMemberInfo( + client: Lark.Client, + memberId: string, + memberIdType: "open_id" | "user_id" | "union_id" = "open_id", +) { + const res = await client.contact.user.get({ + path: { user_id: memberId }, + params: { + user_id_type: memberIdType, + department_id_type: "open_department_id", + }, + }); + + if (res.code !== 0) { + throw new Error(res.msg); + } + + const user = res.data?.user; + return { + member_id: memberId, + member_id_type: memberIdType, + open_id: user?.open_id, + user_id: user?.user_id, + union_id: user?.union_id, + name: user?.name, + en_name: user?.en_name, + nickname: user?.nickname, + email: user?.email, + enterprise_email: user?.enterprise_email, + mobile: user?.mobile, + mobile_visible: user?.mobile_visible, + status: user?.status, + avatar: user?.avatar, + department_ids: user?.department_ids, + department_path: user?.department_path, + leader_user_id: user?.leader_user_id, + city: user?.city, + country: user?.country, + work_station: user?.work_station, + join_time: user?.join_time, + is_tenant_manager: user?.is_tenant_manager, + employee_no: user?.employee_no, + employee_type: user?.employee_type, + description: user?.description, + job_title: user?.job_title, + geo: user?.geo, + }; +} + export function registerFeishuChatTools(api: OpenClawPluginApi) { if (!api.config) { api.logger.debug?.("feishu_chat: No config available, skipping chat tools"); @@ -96,7 +145,7 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) { { name: "feishu_chat", label: "Feishu Chat", - description: "Feishu chat operations. Actions: members, info", + description: "Feishu chat operations. Actions: members, info, member_info", parameters: FeishuChatSchema, async execute(_toolCallId, params) { const p = params as FeishuChatParams; @@ -104,6 +153,9 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) { const client = getClient(); switch (p.action) { case "members": + if (!p.chat_id) { + return json({ error: "chat_id is required for action members" }); + } return json( await getChatMembers( client, @@ -114,7 +166,17 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) { ), ); case "info": + if (!p.chat_id) { + return json({ error: "chat_id is required for action info" }); + } return json(await getChatInfo(client, p.chat_id)); + case "member_info": + if (!p.member_id) { + return json({ error: "member_id is required for action member_info" }); + } + return json( + await getFeishuMemberInfo(client, p.member_id, p.member_id_type ?? "open_id"), + ); default: return json({ error: `Unknown action: ${String(p.action)}` }); } diff --git a/extensions/feishu/src/directory.test.ts b/extensions/feishu/src/directory.test.ts index c06b2fb6c80..805f2f006e9 100644 --- a/extensions/feishu/src/directory.test.ts +++ b/extensions/feishu/src/directory.test.ts @@ -1,27 +1,45 @@ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { describe, expect, it, vi } from "vitest"; +const resolveFeishuAccountMock = vi.hoisted(() => vi.fn()); +const createFeishuClientMock = vi.hoisted(() => vi.fn()); + vi.mock("./accounts.js", () => ({ - resolveFeishuAccount: vi.fn(() => ({ - configured: false, - config: { - allowFrom: ["user:alice", "user:bob"], - dms: { - "user:carla": {}, - }, - groups: { - "chat-1": {}, - }, - groupAllowFrom: ["chat-2"], - }, - })), + resolveFeishuAccount: resolveFeishuAccountMock, })); -import { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.js"; +vi.mock("./client.js", () => ({ + createFeishuClient: createFeishuClientMock, +})); + +import { + listFeishuDirectoryGroups, + listFeishuDirectoryGroupsLive, + listFeishuDirectoryPeers, + listFeishuDirectoryPeersLive, +} from "./directory.js"; describe("feishu directory (config-backed)", () => { const cfg = {} as ClawdbotConfig; + function makeStaticAccount() { + return { + configured: false, + config: { + allowFrom: ["user:alice", "user:bob"], + dms: { + "user:carla": {}, + }, + groups: { + "chat-1": {}, + }, + groupAllowFrom: ["chat-2"], + }, + }; + } + + resolveFeishuAccountMock.mockImplementation(() => makeStaticAccount()); + it("merges allowFrom + dms into peer entries", async () => { const peers = await listFeishuDirectoryPeers({ cfg, query: "a" }); expect(peers).toEqual([ @@ -37,4 +55,64 @@ describe("feishu directory (config-backed)", () => { { kind: "group", id: "chat-2" }, ]); }); + + it("falls back to static peers on live lookup failure by default", async () => { + resolveFeishuAccountMock.mockReturnValueOnce({ + ...makeStaticAccount(), + configured: true, + }); + createFeishuClientMock.mockReturnValueOnce({ + contact: { + user: { + list: vi.fn(async () => { + throw new Error("token expired"); + }), + }, + }, + }); + + const peers = await listFeishuDirectoryPeersLive({ cfg, query: "a" }); + expect(peers).toEqual([ + { kind: "user", id: "alice" }, + { kind: "user", id: "carla" }, + ]); + }); + + it("surfaces live peer lookup failures when fallback is disabled", async () => { + resolveFeishuAccountMock.mockReturnValueOnce({ + ...makeStaticAccount(), + configured: true, + }); + createFeishuClientMock.mockReturnValueOnce({ + contact: { + user: { + list: vi.fn(async () => { + throw new Error("token expired"); + }), + }, + }, + }); + + await expect(listFeishuDirectoryPeersLive({ cfg, fallbackToStatic: false })).rejects.toThrow( + "token expired", + ); + }); + + it("surfaces live group lookup failures when fallback is disabled", async () => { + resolveFeishuAccountMock.mockReturnValueOnce({ + ...makeStaticAccount(), + configured: true, + }); + createFeishuClientMock.mockReturnValueOnce({ + im: { + chat: { + list: vi.fn(async () => ({ code: 999, msg: "forbidden" })), + }, + }, + }); + + await expect(listFeishuDirectoryGroupsLive({ cfg, fallbackToStatic: false })).rejects.toThrow( + "forbidden", + ); + }); }); diff --git a/extensions/feishu/src/directory.ts b/extensions/feishu/src/directory.ts index c6366990204..af6ed8859cf 100644 --- a/extensions/feishu/src/directory.ts +++ b/extensions/feishu/src/directory.ts @@ -15,6 +15,7 @@ export async function listFeishuDirectoryPeersLive(params: { query?: string; limit?: number; accountId?: string; + fallbackToStatic?: boolean; }): Promise { const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); if (!account.configured) { @@ -32,27 +33,32 @@ export async function listFeishuDirectoryPeersLive(params: { }, }); - if (response.code === 0 && response.data?.items) { - for (const user of response.data.items) { - if (user.open_id) { - const q = params.query?.trim().toLowerCase() || ""; - const name = user.name || ""; - if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) { - peers.push({ - kind: "user", - id: user.open_id, - name: name || undefined, - }); - } - } - if (peers.length >= limit) { - break; + if (response.code !== 0) { + throw new Error(response.msg || `code ${response.code}`); + } + + for (const user of response.data?.items ?? []) { + if (user.open_id) { + const q = params.query?.trim().toLowerCase() || ""; + const name = user.name || ""; + if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) { + peers.push({ + kind: "user", + id: user.open_id, + name: name || undefined, + }); } } + if (peers.length >= limit) { + break; + } } return peers; - } catch { + } catch (err) { + if (params.fallbackToStatic === false) { + throw err instanceof Error ? err : new Error("Feishu live peer lookup failed"); + } return listFeishuDirectoryPeers(params); } } @@ -62,6 +68,7 @@ export async function listFeishuDirectoryGroupsLive(params: { query?: string; limit?: number; accountId?: string; + fallbackToStatic?: boolean; }): Promise { const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); if (!account.configured) { @@ -79,27 +86,32 @@ export async function listFeishuDirectoryGroupsLive(params: { }, }); - if (response.code === 0 && response.data?.items) { - for (const chat of response.data.items) { - if (chat.chat_id) { - const q = params.query?.trim().toLowerCase() || ""; - const name = chat.name || ""; - if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) { - groups.push({ - kind: "group", - id: chat.chat_id, - name: name || undefined, - }); - } - } - if (groups.length >= limit) { - break; + if (response.code !== 0) { + throw new Error(response.msg || `code ${response.code}`); + } + + for (const chat of response.data?.items ?? []) { + if (chat.chat_id) { + const q = params.query?.trim().toLowerCase() || ""; + const name = chat.name || ""; + if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) { + groups.push({ + kind: "group", + id: chat.chat_id, + name: name || undefined, + }); } } + if (groups.length >= limit) { + break; + } } return groups; - } catch { + } catch (err) { + if (params.fallbackToStatic === false) { + throw err instanceof Error ? err : new Error("Feishu live group lookup failed"); + } return listFeishuDirectoryGroups(params); } } diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 80555c294ae..67ea2c1b77f 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -195,6 +195,58 @@ describe("sendMediaFeishu msg_type routing", () => { ); }); + it("uses msg_type=media for remote mp4 content even when the filename is generic", async () => { + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("remote-video"), + fileName: "download", + kind: "video", + contentType: "video/mp4", + }); + + await sendMediaFeishu({ + cfg: {} as any, + to: "user:ou_target", + mediaUrl: "https://example.com/video", + }); + + expect(fileCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ file_type: "mp4" }), + }), + ); + expect(messageCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ msg_type: "media" }), + }), + ); + }); + + it("falls back to generic file for unsupported audio formats", async () => { + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("remote-mp3"), + fileName: "song.mp3", + kind: "audio", + contentType: "audio/mpeg", + }); + + await sendMediaFeishu({ + cfg: {} as any, + to: "user:ou_target", + mediaUrl: "https://example.com/song.mp3", + }); + + expect(fileCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ file_type: "stream" }), + }), + ); + expect(messageCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ msg_type: "file" }), + }), + ); + }); + it("configures the media client timeout for image uploads", async () => { await sendMediaFeishu({ cfg: {} as any, @@ -520,4 +572,27 @@ describe("downloadMessageResourceFeishu", () => { expectMediaTimeoutClientConfigured(); expect(result.buffer).toBeInstanceOf(Buffer); }); + + it("extracts content-type and filename metadata from download headers", async () => { + messageResourceGetMock.mockResolvedValueOnce({ + data: Buffer.from("fake-video-data"), + headers: { + "content-type": "video/mp4", + "content-disposition": `attachment; filename="clip.mp4"`, + }, + }); + + const result = await downloadMessageResourceFeishu({ + cfg: {} as any, + messageId: "om_video_msg", + fileKey: "file_key_video", + type: "file", + }); + + expect(result).toMatchObject({ + buffer: Buffer.from("fake-video-data"), + contentType: "video/mp4", + fileName: "clip.mp4", + }); + }); }); diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 45596fe45ed..b7888b7069e 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -2,6 +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 { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; @@ -61,6 +62,75 @@ function extractFeishuUploadKey( return key; } +function readHeaderValue( + headers: Record | undefined, + name: string, +): string | undefined { + if (!headers) { + return undefined; + } + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== target) { + continue; + } + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + if (Array.isArray(value)) { + const first = value.find((entry) => typeof entry === "string" && entry.trim()); + if (typeof first === "string") { + return first.trim(); + } + } + } + return undefined; +} + +function decodeDispositionFileName(value: string): string | undefined { + const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1].trim().replace(/^"(.*)"$/, "$1")); + } catch { + return utf8Match[1].trim().replace(/^"(.*)"$/, "$1"); + } + } + + const plainMatch = value.match(/filename="?([^";]+)"?/i); + return plainMatch?.[1]?.trim(); +} + +function extractFeishuDownloadMetadata(response: unknown): { + contentType?: string; + fileName?: string; +} { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type + const responseAny = response as any; + const headers = + (responseAny.headers as Record | undefined) ?? + (responseAny.header as Record | undefined); + + const contentType = + readHeaderValue(headers, "content-type") ?? + (typeof responseAny.contentType === "string" ? responseAny.contentType : undefined) ?? + (typeof responseAny.mime_type === "string" ? responseAny.mime_type : undefined) ?? + (typeof responseAny.data?.contentType === "string" + ? responseAny.data.contentType + : undefined) ?? + (typeof responseAny.data?.mime_type === "string" ? responseAny.data.mime_type : undefined); + + const disposition = readHeaderValue(headers, "content-disposition"); + const fileName = + (disposition ? decodeDispositionFileName(disposition) : undefined) ?? + (typeof responseAny.file_name === "string" ? responseAny.file_name : undefined) ?? + (typeof responseAny.fileName === "string" ? responseAny.fileName : undefined) ?? + (typeof responseAny.data?.file_name === "string" ? responseAny.data.file_name : undefined) ?? + (typeof responseAny.data?.fileName === "string" ? responseAny.data.fileName : undefined); + + return { contentType, fileName }; +} + async function readFeishuResponseBuffer(params: { response: unknown; tmpDirPrefix: string; @@ -144,7 +214,8 @@ export async function downloadImageFeishu(params: { tmpDirPrefix: "openclaw-feishu-img-", errorPrefix: "Feishu image download failed", }); - return { buffer }; + const meta = extractFeishuDownloadMetadata(response); + return { buffer, contentType: meta.contentType }; } /** @@ -175,7 +246,7 @@ export async function downloadMessageResourceFeishu(params: { tmpDirPrefix: "openclaw-feishu-resource-", errorPrefix: "Feishu message resource download failed", }); - return { buffer }; + return { buffer, ...extractFeishuDownloadMetadata(response) }; } export type UploadImageResult = { @@ -401,6 +472,53 @@ export function detectFileType( } } +function resolveFeishuOutboundMediaKind(params: { fileName: string; contentType?: string }): { + fileType?: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream"; + msgType: "image" | "file" | "audio" | "media"; +} { + const { fileName, contentType } = params; + const ext = path.extname(fileName).toLowerCase(); + const mimeKind = mediaKindFromMime(contentType); + + const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes( + ext, + ); + if (isImageExt || mimeKind === "image") { + return { msgType: "image" }; + } + + if ( + ext === ".opus" || + ext === ".ogg" || + contentType === "audio/ogg" || + contentType === "audio/opus" + ) { + return { fileType: "opus", msgType: "audio" }; + } + + if ( + [".mp4", ".mov", ".avi"].includes(ext) || + contentType === "video/mp4" || + contentType === "video/quicktime" || + contentType === "video/x-msvideo" + ) { + return { fileType: "mp4", msgType: "media" }; + } + + const fileType = detectFileType(fileName); + return { + fileType, + msgType: + fileType === "stream" + ? "file" + : fileType === "opus" + ? "audio" + : fileType === "mp4" + ? "media" + : "file", + }; +} + /** * Upload and send media (image or file) from URL, local path, or buffer. * When mediaUrl is a local path, mediaLocalRoots (from core outbound context) @@ -437,6 +555,7 @@ export async function sendMediaFeishu(params: { let buffer: Buffer; let name: string; + let contentType: string | undefined; if (mediaBuffer) { buffer = mediaBuffer; @@ -449,33 +568,29 @@ export async function sendMediaFeishu(params: { }); buffer = loaded.buffer; name = fileName ?? loaded.fileName ?? "file"; + contentType = loaded.contentType; } else { throw new Error("Either mediaUrl or mediaBuffer must be provided"); } - // Determine if it's an image based on extension - const ext = path.extname(name).toLowerCase(); - const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext); + const routing = resolveFeishuOutboundMediaKind({ fileName: name, contentType }); - if (isImage) { + if (routing.msgType === "image") { const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId }); return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId }); } else { - const fileType = detectFileType(name); const { fileKey } = await uploadFileFeishu({ cfg, file: buffer, fileName: name, - fileType, + fileType: routing.fileType ?? "stream", accountId, }); - // Feishu API: opus -> "audio", mp4/video -> "media" (playable), others -> "file" - const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file"; return sendFileFeishu({ cfg, to, fileKey, - msgType, + msgType: routing.msgType, replyToMessageId, replyInThread, accountId, diff --git a/extensions/feishu/src/pins.ts b/extensions/feishu/src/pins.ts new file mode 100644 index 00000000000..0205acf3aa3 --- /dev/null +++ b/extensions/feishu/src/pins.ts @@ -0,0 +1,108 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import { resolveFeishuAccount } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; + +export type FeishuPin = { + messageId: string; + chatId?: string; + operatorId?: string; + operatorIdType?: string; + createTime?: string; +}; + +function assertFeishuPinApiSuccess(response: { code?: number; msg?: string }, action: string) { + if (response.code !== 0) { + throw new Error(`Feishu ${action} failed: ${response.msg || `code ${response.code}`}`); + } +} + +function normalizePin(pin: { + message_id: string; + chat_id?: string; + operator_id?: string; + operator_id_type?: string; + create_time?: string; +}): FeishuPin { + return { + messageId: pin.message_id, + chatId: pin.chat_id, + operatorId: pin.operator_id, + operatorIdType: pin.operator_id_type, + createTime: pin.create_time, + }; +} + +export async function createPinFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + accountId?: string; +}): Promise { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); + } + + const client = createFeishuClient(account); + const response = await client.im.pin.create({ + data: { + message_id: params.messageId, + }, + }); + assertFeishuPinApiSuccess(response, "pin create"); + return response.data?.pin ? normalizePin(response.data.pin) : null; +} + +export async function removePinFeishu(params: { + cfg: ClawdbotConfig; + messageId: string; + accountId?: string; +}): Promise { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); + } + + const client = createFeishuClient(account); + const response = await client.im.pin.delete({ + path: { + message_id: params.messageId, + }, + }); + assertFeishuPinApiSuccess(response, "pin delete"); +} + +export async function listPinsFeishu(params: { + cfg: ClawdbotConfig; + chatId: string; + startTime?: string; + endTime?: string; + pageSize?: number; + pageToken?: string; + accountId?: string; +}): Promise<{ chatId: string; pins: FeishuPin[]; hasMore: boolean; pageToken?: string }> { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); + } + + const client = createFeishuClient(account); + const response = await client.im.pin.list({ + params: { + chat_id: params.chatId, + ...(params.startTime ? { start_time: params.startTime } : {}), + ...(params.endTime ? { end_time: params.endTime } : {}), + ...(typeof params.pageSize === "number" + ? { page_size: Math.max(1, Math.min(100, Math.floor(params.pageSize))) } + : {}), + ...(params.pageToken ? { page_token: params.pageToken } : {}), + }, + }); + assertFeishuPinApiSuccess(response, "pin list"); + + return { + chatId: params.chatId, + pins: (response.data?.items ?? []).map(normalizePin), + hasMore: response.data?.has_more === true, + pageToken: response.data?.page_token, + }; +} diff --git a/extensions/feishu/src/send.reply-fallback.test.ts b/extensions/feishu/src/send.reply-fallback.test.ts index 610ded167fd..d4a2f023ac1 100644 --- a/extensions/feishu/src/send.reply-fallback.test.ts +++ b/extensions/feishu/src/send.reply-fallback.test.ts @@ -171,6 +171,75 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => { expect(createMock).not.toHaveBeenCalled(); }); + it("fails thread replies instead of falling back to a top-level send", async () => { + replyMock.mockResolvedValue({ + code: 230011, + msg: "The message was withdrawn.", + }); + + await expect( + sendMessageFeishu({ + cfg: {} as never, + to: "chat:oc_group_1", + text: "hello", + replyToMessageId: "om_parent", + replyInThread: true, + }), + ).rejects.toThrow( + "Feishu thread reply failed: reply target is unavailable and cannot safely fall back to a top-level send.", + ); + + expect(createMock).not.toHaveBeenCalled(); + expect(replyMock).toHaveBeenCalledWith({ + path: { message_id: "om_parent" }, + data: expect.objectContaining({ + reply_in_thread: true, + }), + }); + }); + + it("fails thrown withdrawn thread replies instead of falling back to create", async () => { + const sdkError = Object.assign(new Error("request failed"), { code: 230011 }); + replyMock.mockRejectedValue(sdkError); + + await expect( + sendMessageFeishu({ + cfg: {} as never, + to: "chat:oc_group_1", + text: "hello", + replyToMessageId: "om_parent", + replyInThread: true, + }), + ).rejects.toThrow( + "Feishu thread reply failed: reply target is unavailable and cannot safely fall back to a top-level send.", + ); + + expect(createMock).not.toHaveBeenCalled(); + }); + + it("still falls back for non-thread replies to withdrawn targets", async () => { + replyMock.mockResolvedValue({ + code: 230011, + msg: "The message was withdrawn.", + }); + createMock.mockResolvedValue({ + code: 0, + data: { message_id: "om_non_thread_fallback" }, + }); + + await expectFallbackResult( + () => + sendMessageFeishu({ + cfg: {} as never, + to: "user:ou_target", + text: "hello", + replyToMessageId: "om_parent", + replyInThread: false, + }), + "om_non_thread_fallback", + ); + }); + it("re-throws non-withdrawn thrown errors for card messages", async () => { const sdkError = Object.assign(new Error("permission denied"), { code: 99991401 }); replyMock.mockRejectedValue(sdkError); diff --git a/extensions/feishu/src/send.test.ts b/extensions/feishu/src/send.test.ts index 21ef7e53a1a..ecad7a6332e 100644 --- a/extensions/feishu/src/send.test.ts +++ b/extensions/feishu/src/send.test.ts @@ -2,18 +2,25 @@ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildStructuredCard, + editMessageFeishu, getMessageFeishu, listFeishuThreadMessages, resolveFeishuCardTemplate, } from "./send.js"; -const { mockClientGet, mockClientList, mockCreateFeishuClient, mockResolveFeishuAccount } = - vi.hoisted(() => ({ - mockClientGet: vi.fn(), - mockClientList: vi.fn(), - mockCreateFeishuClient: vi.fn(), - mockResolveFeishuAccount: vi.fn(), - })); +const { + mockClientGet, + mockClientList, + mockClientPatch, + mockCreateFeishuClient, + mockResolveFeishuAccount, +} = vi.hoisted(() => ({ + mockClientGet: vi.fn(), + mockClientList: vi.fn(), + mockClientPatch: vi.fn(), + mockCreateFeishuClient: vi.fn(), + mockResolveFeishuAccount: vi.fn(), +})); vi.mock("./client.js", () => ({ createFeishuClient: mockCreateFeishuClient, @@ -23,6 +30,17 @@ vi.mock("./accounts.js", () => ({ resolveFeishuAccount: mockResolveFeishuAccount, })); +vi.mock("./runtime.js", () => ({ + getFeishuRuntime: () => ({ + channel: { + text: { + resolveMarkdownTableMode: () => "preserve", + convertMarkdownTables: (text: string) => text, + }, + }, + }), +})); + describe("getMessageFeishu", () => { beforeEach(() => { vi.clearAllMocks(); @@ -35,6 +53,7 @@ describe("getMessageFeishu", () => { message: { get: mockClientGet, list: mockClientList, + patch: mockClientPatch, }, }, }); @@ -239,6 +258,70 @@ describe("getMessageFeishu", () => { }); }); +describe("editMessageFeishu", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockResolveFeishuAccount.mockReturnValue({ + accountId: "default", + configured: true, + }); + mockCreateFeishuClient.mockReturnValue({ + im: { + message: { + patch: mockClientPatch, + }, + }, + }); + }); + + it("patches post content for text edits", async () => { + mockClientPatch.mockResolvedValueOnce({ code: 0 }); + + const result = await editMessageFeishu({ + cfg: {} as ClawdbotConfig, + messageId: "om_edit", + text: "updated body", + }); + + expect(mockClientPatch).toHaveBeenCalledWith({ + path: { message_id: "om_edit" }, + data: { + content: JSON.stringify({ + zh_cn: { + content: [ + [ + { + tag: "md", + text: "updated body", + }, + ], + ], + }, + }), + }, + }); + expect(result).toEqual({ messageId: "om_edit", contentType: "post" }); + }); + + it("patches interactive content for card edits", async () => { + mockClientPatch.mockResolvedValueOnce({ code: 0 }); + + const result = await editMessageFeishu({ + cfg: {} as ClawdbotConfig, + messageId: "om_card", + card: { schema: "2.0" }, + }); + + expect(mockClientPatch).toHaveBeenCalledWith({ + path: { message_id: "om_card" }, + data: { + content: JSON.stringify({ schema: "2.0" }), + }, + }); + expect(result).toEqual({ messageId: "om_card", contentType: "interactive" }); + }); +}); + describe("resolveFeishuCardTemplate", () => { it("accepts supported Feishu templates", () => { expect(resolveFeishuCardTemplate(" purple ")).toBe("purple"); diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 57c0fbc0600..09015ee593b 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -139,6 +139,12 @@ async function sendReplyOrFallbackDirect( return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); } + const threadReplyFallbackError = params.replyInThread + ? new Error( + "Feishu thread reply failed: reply target is unavailable and cannot safely fall back to a top-level send.", + ) + : null; + let response: { code?: number; msg?: string; data?: { message_id?: string } }; try { response = await client.im.message.reply({ @@ -153,9 +159,15 @@ async function sendReplyOrFallbackDirect( if (!isWithdrawnReplyError(err)) { throw err; } + if (threadReplyFallbackError) { + throw threadReplyFallbackError; + } return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); } if (shouldFallbackFromReplyTarget(response)) { + if (threadReplyFallbackError) { + throw threadReplyFallbackError; + } return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); } assertFeishuMessageApiSuccess(response, params.replyErrorPrefix); @@ -406,7 +418,7 @@ export type SendFeishuMessageParams = { accountId?: string; }; -function buildFeishuPostMessagePayload(params: { messageText: string }): { +export function buildFeishuPostMessagePayload(params: { messageText: string }): { content: string; msgType: string; } { @@ -486,6 +498,59 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise; + accountId?: string; +}): Promise<{ messageId: string; contentType: "post" | "interactive" }> { + const { cfg, messageId, text, card, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); + } + + const hasText = typeof text === "string" && text.trim().length > 0; + const hasCard = Boolean(card); + if (hasText === hasCard) { + throw new Error("Feishu edit requires exactly one of text or card."); + } + + const client = createFeishuClient(account); + + if (card) { + const content = JSON.stringify(card); + const response = await client.im.message.patch({ + path: { message_id: messageId }, + data: { content }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`); + } + + return { messageId, contentType: "interactive" }; + } + + const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "feishu", + }); + const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text!, tableMode); + const payload = buildFeishuPostMessagePayload({ messageText }); + const response = await client.im.message.patch({ + path: { message_id: messageId }, + data: { content: payload.content }, + }); + + if (response.code !== 0) { + throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`); + } + + return { messageId, contentType: "post" }; +} + export async function updateCardFeishu(params: { cfg: ClawdbotConfig; messageId: string; @@ -627,41 +692,3 @@ export async function sendMarkdownCardFeishu(params: { const card = buildMarkdownCard(cardText); return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId }); } - -/** - * Edit an existing text message. - * Note: Feishu only allows editing messages within 24 hours. - */ -export async function editMessageFeishu(params: { - cfg: ClawdbotConfig; - messageId: string; - text: string; - accountId?: string; -}): Promise { - const { cfg, messageId, text, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({ - cfg, - channel: "feishu", - }); - const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode); - - const { content, msgType } = buildFeishuPostMessagePayload({ messageText }); - - const response = await client.im.message.update({ - path: { message_id: messageId }, - data: { - msg_type: msgType, - content, - }, - }); - - if (response.code !== 0) { - throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`); - } -} diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index e0711ecf8ae..0e6c846e75d 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -319,6 +319,8 @@ function buildReactionSchema() { function buildFetchSchema() { return { limit: Type.Optional(Type.Number()), + pageSize: Type.Optional(Type.Number()), + pageToken: Type.Optional(Type.String()), before: Type.Optional(Type.String()), after: Type.Optional(Type.String()), around: Type.Optional(Type.String()), @@ -386,16 +388,27 @@ function buildChannelTargetSchema() { channelId: Type.Optional( Type.String({ description: "Channel id filter (search/thread list/event create)." }), ), + chatId: Type.Optional( + Type.String({ description: "Chat id for chat-scoped metadata actions." }), + ), channelIds: Type.Optional( Type.Array(Type.String({ description: "Channel id filter (repeatable)." })), ), + memberId: Type.Optional(Type.String()), + memberIdType: Type.Optional(Type.String()), guildId: Type.Optional(Type.String()), userId: Type.Optional(Type.String()), + openId: Type.Optional(Type.String()), + unionId: Type.Optional(Type.String()), authorId: Type.Optional(Type.String()), authorIds: Type.Optional(Type.Array(Type.String())), roleId: Type.Optional(Type.String()), roleIds: Type.Optional(Type.Array(Type.String())), participant: Type.Optional(Type.String()), + includeMembers: Type.Optional(Type.Boolean()), + members: Type.Optional(Type.Boolean()), + scope: Type.Optional(Type.String()), + kind: Type.Optional(Type.String()), }; } diff --git a/src/infra/outbound/message-action-normalization.test.ts b/src/infra/outbound/message-action-normalization.test.ts index 87fa7a8503c..2ee5f35b3dd 100644 --- a/src/infra/outbound/message-action-normalization.test.ts +++ b/src/infra/outbound/message-action-normalization.test.ts @@ -115,6 +115,47 @@ describe("normalizeMessageActionInput", () => { expect("to" in normalized).toBe(false); }); + it("keeps Feishu message and chat aliases without forcing canonical targets", () => { + const pin = normalizeMessageActionInput({ + action: "pin", + args: { + channel: "feishu", + messageId: "om_123", + }, + }); + const listPins = normalizeMessageActionInput({ + action: "list-pins", + args: { + channel: "feishu", + chatId: "oc_123", + }, + }); + + expect(pin.messageId).toBe("om_123"); + expect("target" in pin).toBe(false); + expect("to" in pin).toBe(false); + expect(listPins.chatId).toBe("oc_123"); + expect("target" in listPins).toBe(false); + expect("to" in listPins).toBe(false); + }); + + it("still backfills target for non-Feishu read actions with messageId-only input", () => { + const normalized = normalizeMessageActionInput({ + action: "read", + args: { + channel: "slack", + messageId: "123.456", + }, + toolContext: { + currentChannelId: "C12345678", + currentChannelProvider: "slack", + }, + }); + + expect(normalized.target).toBe("C12345678"); + expect(normalized.messageId).toBe("123.456"); + }); + it("maps legacy channelId inputs through canonical target for channel-id actions", () => { const normalized = normalizeMessageActionInput({ action: "channel-info", diff --git a/src/infra/outbound/message-action-normalization.ts b/src/infra/outbound/message-action-normalization.ts index a4b4f4829bd..ff40ca45e6a 100644 --- a/src/infra/outbound/message-action-normalization.ts +++ b/src/infra/outbound/message-action-normalization.ts @@ -16,6 +16,10 @@ export function normalizeMessageActionInput(params: { }): Record { const normalizedArgs = { ...params.args }; const { action, toolContext } = params; + const explicitChannel = + typeof normalizedArgs.channel === "string" ? normalizedArgs.channel.trim() : ""; + const inferredChannel = + explicitChannel || normalizeMessageChannel(toolContext?.currentChannelProvider) || ""; const explicitTarget = typeof normalizedArgs.target === "string" ? normalizedArgs.target.trim() : ""; @@ -34,7 +38,7 @@ export function normalizeMessageActionInput(params: { !explicitTarget && !hasLegacyTarget && actionRequiresTarget(action) && - !actionHasTarget(action, normalizedArgs) + !actionHasTarget(action, normalizedArgs, { channel: inferredChannel }) ) { const inferredTarget = toolContext?.currentChannelId?.trim(); if (inferredTarget) { @@ -54,17 +58,17 @@ export function normalizeMessageActionInput(params: { } } - const explicitChannel = - typeof normalizedArgs.channel === "string" ? normalizedArgs.channel.trim() : ""; if (!explicitChannel) { - const inferredChannel = normalizeMessageChannel(toolContext?.currentChannelProvider); if (inferredChannel && isDeliverableMessageChannel(inferredChannel)) { normalizedArgs.channel = inferredChannel; } } applyTargetToParams({ action, args: normalizedArgs }); - if (actionRequiresTarget(action) && !actionHasTarget(action, normalizedArgs)) { + if ( + actionRequiresTarget(action) && + !actionHasTarget(action, normalizedArgs, { channel: inferredChannel }) + ) { throw new Error(`Action ${action} requires a target.`); } diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 00c4bafef11..952bf16f51c 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -15,6 +15,105 @@ function createAlwaysConfiguredPluginConfig(account: Record = { } describe("runMessageAction plugin dispatch", () => { + describe("alias-based plugin action dispatch", () => { + const handleAction = vi.fn(async ({ params }: { params: Record }) => + jsonResult({ + ok: true, + params, + }), + ); + + const feishuLikePlugin: ChannelPlugin = { + id: "feishu", + meta: { + id: "feishu", + label: "Feishu", + selectionLabel: "Feishu", + docsPath: "/channels/feishu", + blurb: "Feishu action dispatch test plugin.", + }, + capabilities: { chatTypes: ["direct", "channel"] }, + config: createAlwaysConfiguredPluginConfig(), + actions: { + listActions: () => ["pin", "list-pins", "member-info"], + supportsAction: ({ action }) => + action === "pin" || action === "list-pins" || action === "member-info", + handleAction, + }, + }; + + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "feishu", + source: "test", + plugin: feishuLikePlugin, + }, + ]), + ); + handleAction.mockClear(); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + vi.clearAllMocks(); + }); + + it("dispatches messageId/chatId-based Feishu actions through the shared runner", async () => { + await runMessageAction({ + cfg: { + channels: { + feishu: { + enabled: true, + }, + }, + } as OpenClawConfig, + action: "pin", + params: { + channel: "feishu", + messageId: "om_123", + }, + dryRun: false, + }); + + await runMessageAction({ + cfg: { + channels: { + feishu: { + enabled: true, + }, + }, + } as OpenClawConfig, + action: "list-pins", + params: { + channel: "feishu", + chatId: "oc_123", + }, + dryRun: false, + }); + + expect(handleAction).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + action: "pin", + params: expect.objectContaining({ + messageId: "om_123", + }), + }), + ); + expect(handleAction).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + action: "list-pins", + params: expect.objectContaining({ + chatId: "oc_123", + }), + }), + ); + }); + }); + describe("media caption behavior", () => { afterEach(() => { setActivePluginRegistry(createTestRegistry([])); diff --git a/src/infra/outbound/message-action-spec.test.ts b/src/infra/outbound/message-action-spec.test.ts index 138f61e08a0..cc8a127799f 100644 --- a/src/infra/outbound/message-action-spec.test.ts +++ b/src/infra/outbound/message-action-spec.test.ts @@ -20,12 +20,25 @@ describe("actionHasTarget", () => { }); it("detects alias targets for message and chat actions", () => { + expect(actionHasTarget("read", { messageId: "msg_123" }, { channel: "feishu" })).toBe(true); expect(actionHasTarget("edit", { messageId: " msg_123 " })).toBe(true); + expect(actionHasTarget("pin", { messageId: "msg_123" }, { channel: "feishu" })).toBe(true); + expect(actionHasTarget("unpin", { messageId: "msg_123" }, { channel: "feishu" })).toBe(true); + expect(actionHasTarget("list-pins", { chatId: "oc_123" }, { channel: "feishu" })).toBe(true); + expect(actionHasTarget("channel-info", { chatId: "oc_123" }, { channel: "feishu" })).toBe(true); expect(actionHasTarget("react", { chatGuid: "chat-guid" })).toBe(true); expect(actionHasTarget("react", { chatIdentifier: "chat-id" })).toBe(true); expect(actionHasTarget("react", { chatId: 42 })).toBe(true); }); + it("scopes Feishu-only aliases to Feishu", () => { + expect(actionHasTarget("read", { messageId: "msg_123" })).toBe(false); + expect(actionHasTarget("pin", { messageId: "msg_123" }, { channel: "slack" })).toBe(false); + expect(actionHasTarget("channel-info", { chatId: "oc_123" }, { channel: "discord" })).toBe( + false, + ); + }); + it("rejects blank and non-finite alias targets", () => { expect(actionHasTarget("edit", { messageId: " " })).toBe(false); expect(actionHasTarget("react", { chatGuid: "" })).toBe(false); diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index f4f715d869d..a71bc35b6fb 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -60,15 +60,25 @@ export const MESSAGE_ACTION_TARGET_MODE: Record> = { - unsend: ["messageId"], - edit: ["messageId"], - react: ["chatGuid", "chatIdentifier", "chatId"], - renameGroup: ["chatGuid", "chatIdentifier", "chatId"], - setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"], - addParticipant: ["chatGuid", "chatIdentifier", "chatId"], - removeParticipant: ["chatGuid", "chatIdentifier", "chatId"], - leaveGroup: ["chatGuid", "chatIdentifier", "chatId"], +type ActionTargetAliasSpec = { + aliases: string[]; + channels?: string[]; +}; + +const ACTION_TARGET_ALIASES: Partial> = { + read: { aliases: ["messageId"], channels: ["feishu"] }, + unsend: { aliases: ["messageId"] }, + edit: { aliases: ["messageId"] }, + pin: { aliases: ["messageId"], channels: ["feishu"] }, + unpin: { aliases: ["messageId"], channels: ["feishu"] }, + "list-pins": { aliases: ["chatId"], channels: ["feishu"] }, + "channel-info": { aliases: ["chatId"], channels: ["feishu"] }, + react: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, + renameGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, + setGroupIcon: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, + addParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, + removeParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, + leaveGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, }; export function actionRequiresTarget(action: ChannelMessageActionName): boolean { @@ -78,6 +88,7 @@ export function actionRequiresTarget(action: ChannelMessageActionName): boolean export function actionHasTarget( action: ChannelMessageActionName, params: Record, + options?: { channel?: string }, ): boolean { const to = typeof params.to === "string" ? params.to.trim() : ""; if (to) { @@ -87,11 +98,17 @@ export function actionHasTarget( if (channelId) { return true; } - const aliases = ACTION_TARGET_ALIASES[action]; - if (!aliases) { + const spec = ACTION_TARGET_ALIASES[action]; + if (!spec) { return false; } - return aliases.some((alias) => { + if ( + spec.channels && + (!options?.channel || !spec.channels.includes(options.channel.trim().toLowerCase())) + ) { + return false; + } + return spec.aliases.some((alias) => { const value = params[alias]; if (typeof value === "string") { return value.trim().length > 0; From edab939f4d5036fd436a47a674d82f69acc7710c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 07:02:44 +0000 Subject: [PATCH 072/133] fix: make docs i18n use gpt-5.4 overrides --- scripts/docs-i18n/pi_rpc_client.go | 4 +-- scripts/docs-i18n/process.go | 12 ++++---- scripts/docs-i18n/translator.go | 28 ++++++++++++++--- scripts/docs-i18n/translator_test.go | 26 +++++++++++++++- scripts/docs-i18n/util.go | 46 +++++++++++++++++++++++++--- scripts/docs-i18n/util_test.go | 41 +++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 17 deletions(-) create mode 100644 scripts/docs-i18n/util_test.go diff --git a/scripts/docs-i18n/pi_rpc_client.go b/scripts/docs-i18n/pi_rpc_client.go index d995c6a171f..7535b04799b 100644 --- a/scripts/docs-i18n/pi_rpc_client.go +++ b/scripts/docs-i18n/pi_rpc_client.go @@ -73,8 +73,8 @@ func startDocsPiClient(ctx context.Context, options docsPiClientOptions) (*docsP args := append([]string{}, command.Args...) args = append(args, "--mode", "rpc", - "--provider", "anthropic", - "--model", modelVersion, + "--provider", docsPiProvider(), + "--model", docsPiModel(), "--thinking", options.Thinking, "--no-session", ) diff --git a/scripts/docs-i18n/process.go b/scripts/docs-i18n/process.go index c792d3c11bc..cbcd1d4abc2 100644 --- a/scripts/docs-i18n/process.go +++ b/scripts/docs-i18n/process.go @@ -64,8 +64,8 @@ func processFile(ctx context.Context, translator *PiTranslator, tm *TranslationM TextHash: seg.TextHash, Text: seg.Text, Translated: translated, - Provider: providerName, - Model: modelVersion, + Provider: docsPiProvider(), + Model: docsPiModel(), SrcLang: srcLang, TgtLang: tgtLang, UpdatedAt: time.Now().UTC().Format(time.RFC3339), @@ -121,8 +121,8 @@ func encodeFrontMatter(frontData map[string]any, relPath string, source []byte) frontData["x-i18n"] = map[string]any{ "source_path": relPath, "source_hash": hashBytes(source), - "provider": providerName, - "model": modelVersion, + "provider": docsPiProvider(), + "model": docsPiModel(), "workflow": workflowVersion, "generated_at": time.Now().UTC().Format(time.RFC3339), } @@ -191,8 +191,8 @@ func translateSnippet(ctx context.Context, translator *PiTranslator, tm *Transla TextHash: textHash, Text: textValue, Translated: translated, - Provider: providerName, - Model: modelVersion, + Provider: docsPiProvider(), + Model: docsPiModel(), SrcLang: srcLang, TgtLang: tgtLang, UpdatedAt: time.Now().UTC().Format(time.RFC3339), diff --git a/scripts/docs-i18n/translator.go b/scripts/docs-i18n/translator.go index 122a30ec5d5..59a41959af1 100644 --- a/scripts/docs-i18n/translator.go +++ b/scripts/docs-i18n/translator.go @@ -4,14 +4,16 @@ import ( "context" "errors" "fmt" + "os" "strings" "time" ) const ( - translateMaxAttempts = 3 - translateBaseDelay = 15 * time.Second - translatePromptTimeout = 2 * time.Minute + translateMaxAttempts = 3 + translateBaseDelay = 15 * time.Second + defaultPromptTimeout = 2 * time.Minute + envDocsI18nPromptTimeout = "OPENCLAW_DOCS_I18N_PROMPT_TIMEOUT" ) var errEmptyTranslation = errors.New("empty translation") @@ -112,10 +114,16 @@ func isRetryableTranslateError(err error) bool { if err == nil { return false } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return false + } if errors.Is(err, errEmptyTranslation) { return true } message := strings.ToLower(err.Error()) + if strings.Contains(message, "authentication failed") { + return false + } return strings.Contains(message, "placeholder missing") || strings.Contains(message, "rate limit") || strings.Contains(message, "429") } @@ -142,7 +150,7 @@ type promptRunner interface { } func runPrompt(ctx context.Context, client promptRunner, message string) (string, error) { - promptCtx, cancel := context.WithTimeout(ctx, translatePromptTimeout) + promptCtx, cancel := context.WithTimeout(ctx, docsI18nPromptTimeout()) defer cancel() result, err := client.Prompt(promptCtx, message) @@ -171,3 +179,15 @@ func normalizeThinking(value string) string { return "high" } } + +func docsI18nPromptTimeout() time.Duration { + value := strings.TrimSpace(os.Getenv(envDocsI18nPromptTimeout)) + if value == "" { + return defaultPromptTimeout + } + parsed, err := time.ParseDuration(value) + if err != nil || parsed <= 0 { + return defaultPromptTimeout + } + return parsed +} diff --git a/scripts/docs-i18n/translator_test.go b/scripts/docs-i18n/translator_test.go index 3872d6dff07..759f3aa276a 100644 --- a/scripts/docs-i18n/translator_test.go +++ b/scripts/docs-i18n/translator_test.go @@ -50,11 +50,35 @@ func TestRunPromptAddsTimeout(t *testing.T) { } remaining := time.Until(deadline) - if remaining <= time.Minute || remaining > translatePromptTimeout { + if remaining <= time.Minute || remaining > docsI18nPromptTimeout() { t.Fatalf("unexpected timeout window %s", remaining) } } +func TestDocsI18nPromptTimeoutUsesEnvOverride(t *testing.T) { + t.Setenv(envDocsI18nPromptTimeout, "5m") + + if got := docsI18nPromptTimeout(); got != 5*time.Minute { + t.Fatalf("expected 5m timeout, got %s", got) + } +} + +func TestIsRetryableTranslateErrorRejectsDeadlineExceeded(t *testing.T) { + t.Parallel() + + if isRetryableTranslateError(context.DeadlineExceeded) { + t.Fatal("deadline exceeded should not retry") + } +} + +func TestIsRetryableTranslateErrorRejectsAuthenticationFailures(t *testing.T) { + t.Parallel() + + if isRetryableTranslateError(errors.New(`Authentication failed for "openai"`)) { + t.Fatal("auth failures should not retry") + } +} + func TestRunPromptIncludesStderr(t *testing.T) { t.Parallel() diff --git a/scripts/docs-i18n/util.go b/scripts/docs-i18n/util.go index 3be70ee3076..e7a8e6daaf3 100644 --- a/scripts/docs-i18n/util.go +++ b/scripts/docs-i18n/util.go @@ -10,13 +10,24 @@ import ( ) const ( - workflowVersion = 15 - providerName = "pi" - modelVersion = "claude-opus-4-6" + workflowVersion = 15 + docsI18nEngineName = "pi" + envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER" + envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL" + defaultOpenAIModel = "gpt-5.4" + defaultAnthropicModel = "claude-opus-4-6" + defaultFallbackProvider = "openai" + defaultFallbackModelName = defaultOpenAIModel ) func cacheNamespace() string { - return fmt.Sprintf("wf=%d|provider=%s|model=%s", workflowVersion, providerName, modelVersion) + return fmt.Sprintf( + "wf=%d|engine=%s|provider=%s|model=%s", + workflowVersion, + docsI18nEngineName, + docsPiProvider(), + docsPiModel(), + ) } func cacheKey(namespace, srcLang, tgtLang, segmentID, textHash string) string { @@ -40,6 +51,33 @@ func normalizeText(text string) string { return strings.Join(strings.Fields(strings.TrimSpace(text)), " ") } +func docsPiProvider() string { + if value := strings.TrimSpace(os.Getenv(envDocsI18nProvider)); value != "" { + return value + } + if strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) != "" { + return "openai" + } + if strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) != "" { + return "anthropic" + } + return defaultFallbackProvider +} + +func docsPiModel() string { + if value := strings.TrimSpace(os.Getenv(envDocsI18nModel)); value != "" { + return value + } + switch docsPiProvider() { + case "anthropic": + return defaultAnthropicModel + case "openai": + return defaultOpenAIModel + default: + return defaultFallbackModelName + } +} + func segmentID(relPath, textHash string) string { shortHash := textHash if len(shortHash) > 16 { diff --git a/scripts/docs-i18n/util_test.go b/scripts/docs-i18n/util_test.go new file mode 100644 index 00000000000..77b5ca82a73 --- /dev/null +++ b/scripts/docs-i18n/util_test.go @@ -0,0 +1,41 @@ +package main + +import "testing" + +func TestDocsPiProviderPrefersExplicitOverride(t *testing.T) { + t.Setenv(envDocsI18nProvider, "anthropic") + t.Setenv("OPENAI_API_KEY", "openai-key") + t.Setenv("ANTHROPIC_API_KEY", "anthropic-key") + + if got := docsPiProvider(); got != "anthropic" { + t.Fatalf("expected anthropic override, got %q", got) + } +} + +func TestDocsPiProviderPrefersOpenAIEnvWhenAvailable(t *testing.T) { + t.Setenv(envDocsI18nProvider, "") + t.Setenv("OPENAI_API_KEY", "openai-key") + t.Setenv("ANTHROPIC_API_KEY", "anthropic-key") + + if got := docsPiProvider(); got != "openai" { + t.Fatalf("expected openai provider, got %q", got) + } +} + +func TestDocsPiModelUsesProviderDefault(t *testing.T) { + t.Setenv(envDocsI18nProvider, "anthropic") + t.Setenv(envDocsI18nModel, "") + + if got := docsPiModel(); got != defaultAnthropicModel { + t.Fatalf("expected anthropic default model, got %q", got) + } +} + +func TestDocsPiModelPrefersExplicitOverride(t *testing.T) { + t.Setenv(envDocsI18nProvider, "openai") + t.Setenv(envDocsI18nModel, "gpt-5.2") + + if got := docsPiModel(); got != "gpt-5.2" { + t.Fatalf("expected explicit model override, got %q", got) + } +} From 00ef214d59d21d383cb8b18b7810ad6b0241693a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 07:02:46 +0000 Subject: [PATCH 073/133] docs: regenerate zh-CN onboarding references --- docs/zh-CN/automation/hooks.md | 539 ++- docs/zh-CN/channels/bluebubbles.md | 237 +- docs/zh-CN/channels/feishu.md | 565 ++- docs/zh-CN/channels/nostr.md | 119 +- docs/zh-CN/channels/synology-chat.md | 138 + docs/zh-CN/cli/index.md | 417 +- docs/zh-CN/cli/onboard.md | 152 +- docs/zh-CN/cli/setup.md | 14 +- docs/zh-CN/concepts/agent-workspace.md | 142 +- docs/zh-CN/concepts/model-providers.md | 501 ++- docs/zh-CN/concepts/models.md | 151 +- docs/zh-CN/concepts/oauth.md | 115 +- docs/zh-CN/gateway/authentication.md | 112 +- docs/zh-CN/gateway/configuration-reference.md | 3103 ++++++++++++++ docs/zh-CN/gateway/configuration.md | 3770 +++-------------- docs/zh-CN/gateway/local-models.md | 78 +- docs/zh-CN/gateway/multiple-gateways.md | 78 +- docs/zh-CN/help/faq.md | 4 +- docs/zh-CN/install/exe-dev.md | 68 +- docs/zh-CN/install/index.md | 296 +- docs/zh-CN/install/installer.md | 452 +- docs/zh-CN/install/macos-vm.md | 158 +- docs/zh-CN/platforms/digitalocean.md | 160 +- docs/zh-CN/platforms/index.md | 32 +- docs/zh-CN/platforms/linux.md | 35 +- docs/zh-CN/platforms/raspberry-pi.md | 267 +- docs/zh-CN/platforms/windows.md | 155 +- docs/zh-CN/providers/anthropic.md | 200 +- docs/zh-CN/providers/cloudflare-ai-gateway.md | 78 + docs/zh-CN/providers/glm.md | 39 +- docs/zh-CN/providers/huggingface.md | 216 + docs/zh-CN/providers/index.md | 46 +- docs/zh-CN/providers/kilocode.md | 80 + docs/zh-CN/providers/litellm.md | 160 + docs/zh-CN/providers/minimax.md | 156 +- docs/zh-CN/providers/mistral.md | 61 + docs/zh-CN/providers/models.md | 40 +- docs/zh-CN/providers/moonshot.md | 69 +- docs/zh-CN/providers/nvidia.md | 62 + docs/zh-CN/providers/ollama.md | 251 +- docs/zh-CN/providers/openai.md | 282 +- docs/zh-CN/providers/opencode-go.md | 52 + docs/zh-CN/providers/opencode.md | 60 +- docs/zh-CN/providers/openrouter.md | 21 +- docs/zh-CN/providers/qianfan.md | 45 +- docs/zh-CN/providers/sglang.md | 111 + docs/zh-CN/providers/synthetic.md | 86 +- docs/zh-CN/providers/together.md | 72 + docs/zh-CN/providers/venice.md | 255 +- docs/zh-CN/providers/vercel-ai-gateway.md | 35 +- docs/zh-CN/providers/xiaomi.md | 25 +- docs/zh-CN/providers/zai.md | 42 +- docs/zh-CN/reference/wizard.md | 243 +- docs/zh-CN/start/getting-started.md | 271 +- docs/zh-CN/start/onboarding-overview.md | 58 + docs/zh-CN/start/wizard-cli-automation.md | 222 + docs/zh-CN/start/wizard-cli-reference.md | 306 ++ docs/zh-CN/start/wizard.md | 371 +- docs/zh-CN/tools/plugin.md | 1283 +++++- 59 files changed, 10929 insertions(+), 6227 deletions(-) create mode 100644 docs/zh-CN/channels/synology-chat.md create mode 100644 docs/zh-CN/gateway/configuration-reference.md create mode 100644 docs/zh-CN/providers/cloudflare-ai-gateway.md create mode 100644 docs/zh-CN/providers/huggingface.md create mode 100644 docs/zh-CN/providers/kilocode.md create mode 100644 docs/zh-CN/providers/litellm.md create mode 100644 docs/zh-CN/providers/mistral.md create mode 100644 docs/zh-CN/providers/nvidia.md create mode 100644 docs/zh-CN/providers/opencode-go.md create mode 100644 docs/zh-CN/providers/sglang.md create mode 100644 docs/zh-CN/providers/together.md create mode 100644 docs/zh-CN/start/onboarding-overview.md create mode 100644 docs/zh-CN/start/wizard-cli-automation.md create mode 100644 docs/zh-CN/start/wizard-cli-reference.md diff --git a/docs/zh-CN/automation/hooks.md b/docs/zh-CN/automation/hooks.md index b5806e2bdd0..b0615569eec 100644 --- a/docs/zh-CN/automation/hooks.md +++ b/docs/zh-CN/automation/hooks.md @@ -1,60 +1,61 @@ --- read_when: - - 你想为 /new、/reset、/stop 和智能体生命周期事件实现事件驱动自动化 - - 你想构建、安装或调试 hooks + - 你希望为 `/new`、`/reset`、`/stop` 和智能体生命周期事件使用事件驱动自动化 + - 你希望构建、安装或调试 Hooks summary: Hooks:用于命令和生命周期事件的事件驱动自动化 title: Hooks x-i18n: - generated_at: "2026-02-03T07:50:59Z" - model: claude-opus-4-5 - provider: pi - source_hash: 853227a0f1abd20790b425fa64dda60efc6b5f93c1b13ecd2dcb788268f71d79 + generated_at: "2026-03-16T06:21:34Z" + model: gpt-5.4 + provider: openai + source_hash: fc1370a05127d778eb685f687ee9a52062aa6f5c895e80152b9de41c3a02c592 source_path: automation/hooks.md workflow: 15 --- # Hooks -Hooks 提供了一个可扩展的事件驱动系统,用于响应智能体命令和事件自动执行操作。Hooks 从目录中自动发现,可以通过 CLI 命令管理,类似于 OpenClaw 中 Skills 的工作方式。 +Hooks 提供了一个可扩展的事件驱动系统,用于在响应智能体命令和事件时自动执行操作。Hooks 会从目录中自动发现,并且可以通过 CLI 命令进行管理,方式与 OpenClaw 中的 Skills 类似。 -## 入门指南 +## 熟悉基础 -Hooks 是在事件发生时运行的小脚本。有两种类型: +Hooks 是在某些事情发生时运行的小脚本。它们有两种类型: -- **Hooks**(本页):当智能体事件触发时在 Gateway 网关内运行,如 `/new`、`/reset`、`/stop` 或生命周期事件。 -- **Webhooks**:外部 HTTP webhooks,让其他系统触发 OpenClaw 中的工作。参见 [Webhook Hooks](/automation/webhook) 或使用 `openclaw webhooks` 获取 Gmail 助手命令。 +- **Hooks**(本页):当智能体事件触发时,在 Gateway 网关内运行,例如 `/new`、`/reset`、`/stop` 或生命周期事件。 +- **Webhooks**:外部 HTTP webhook,可让其他系统在 OpenClaw 中触发工作。请参阅 [Webhook Hooks](/automation/webhook),或使用 `openclaw webhooks` 获取 Gmail 辅助命令。 -Hooks 也可以捆绑在插件中;参见 [插件](/tools/plugin#plugin-hooks)。 +Hooks 也可以打包在插件中;请参阅 [Plugins](/tools/plugin#plugin-hooks)。 常见用途: -- 重置会话时保存记忆快照 -- 保留命令审计跟踪用于故障排除或合规 -- 会话开始或结束时触发后续自动化 -- 事件触发时向智能体工作区写入文件或调用外部 API +- 当你重置会话时保存一份内存快照 +- 为故障排除或合规保留命令审计轨迹 +- 当会话开始或结束时触发后续自动化 +- 当事件触发时,将文件写入智能体工作区或调用外部 API -如果你能写一个小的 TypeScript 函数,你就能写一个 hook。Hooks 会自动发现,你可以通过 CLI 启用或禁用它们。 +如果你会写一个小型 TypeScript 函数,你就能编写一个 hook。Hooks 会被自动发现,你可以通过 CLI 启用或禁用它们。 -## 概述 +## 概览 -hooks 系统允许你: +Hooks 系统允许你: -- 在发出 `/new` 时将会话上下文保存到记忆 +- 当发出 `/new` 时,将会话上下文保存到 memory - 记录所有命令以供审计 - 在智能体生命周期事件上触发自定义自动化 - 在不修改核心代码的情况下扩展 OpenClaw 的行为 -## 入门 +## 入门指南 -### 捆绑的 Hooks +### 内置 Hooks -OpenClaw 附带三个自动发现的捆绑 hooks: +OpenClaw 自带四个会被自动发现的内置 hook: -- **💾 session-memory**:当你发出 `/new` 时将会话上下文保存到智能体工作区(默认 `~/.openclaw/workspace/memory/`) +- **💾 session-memory**:当你发出 `/new` 时,将会话上下文保存到你的智能体工作区(默认是 `~/.openclaw/workspace/memory/`) +- **📎 bootstrap-extra-files**:在 `agent:bootstrap` 期间,从已配置的 glob/路径模式中注入额外的工作区引导文件 - **📝 command-logger**:将所有命令事件记录到 `~/.openclaw/logs/commands.log` - **🚀 boot-md**:当 Gateway 网关启动时运行 `BOOT.md`(需要启用内部 hooks) -列出可用的 hooks: +列出可用 hooks: ```bash openclaw hooks list @@ -80,35 +81,40 @@ openclaw hooks info session-memory ### 新手引导 -在新手引导期间(`openclaw onboard`),你将被提示启用推荐的 hooks。向导会自动发现符合条件的 hooks 并呈现供选择。 +在新手引导期间(`openclaw onboard`),系统会提示你启用推荐的 hooks。向导会自动发现符合条件的 hooks 并供你选择。 ## Hook 发现 -Hooks 从三个目录自动发现(按优先级顺序): +Hooks 会从三个目录中自动发现(按优先级顺序): -1. **工作区 hooks**:`/hooks/`(每智能体,最高优先级) -2. **托管 hooks**:`~/.openclaw/hooks/`(用户安装,跨工作区共享) -3. **捆绑 hooks**:`/dist/hooks/bundled/`(随 OpenClaw 附带) +1. **工作区 hooks**:`/hooks/`(每个智能体单独配置,优先级最高) +2. **托管 hooks**:`~/.openclaw/hooks/`(用户安装,在各工作区之间共享) +3. **内置 hooks**:`/dist/hooks/bundled/`(随 OpenClaw 一起提供) -托管 hook 目录可以是**单个 hook** 或 **hook 包**(包目录)。 +托管 hook 目录既可以是 **单个 hook**,也可以是 **hook 包**(包目录)。 -每个 hook 是一个包含以下内容的目录: +每个 hook 都是一个包含以下内容的目录: ``` my-hook/ ├── HOOK.md # 元数据 + 文档 -└── handler.ts # 处理程序实现 +└── handler.ts # 处理器实现 ``` -## Hook 包(npm/archives) +## Hook 包(npm/归档) -Hook 包是标准的 npm 包,通过 `package.json` 中的 `openclaw.hooks` 导出一个或多个 hooks。使用以下命令安装: +Hook 包是标准的 npm 包,它们通过 `package.json` 中的 `openclaw.hooks` 导出一个或多个 hook。使用以下命令安装它们: ```bash openclaw hooks install ``` -示例 `package.json`: +npm spec 仅支持注册表形式(包名 + 可选的精确版本或 dist-tag)。 +Git/URL/file spec 和 semver 范围会被拒绝。 + +裸 spec 和 `@latest` 会保持在稳定轨道上。如果 npm 将其中任意一种解析为预发布版本,OpenClaw 会停止并要求你通过预发布标签(例如 `@beta`/`@rc`)或精确的预发布版本显式选择加入。 + +`package.json` 示例: ```json { @@ -120,19 +126,23 @@ openclaw hooks install } ``` -每个条目指向包含 `HOOK.md` 和 `handler.ts`(或 `index.ts`)的 hook 目录。 -Hook 包可以附带依赖;它们将安装在 `~/.openclaw/hooks/` 下。 +每个条目都指向一个包含 `HOOK.md` 和 `handler.ts`(或 `index.ts`)的 hook 目录。 +Hook 包可以携带依赖;它们会安装到 `~/.openclaw/hooks/` 下。 +每个 `openclaw.hooks` 条目在解析符号链接后都必须保持在包目录内部;超出目录范围的条目会被拒绝。 + +安全说明:`openclaw hooks install` 会使用 `npm install --ignore-scripts` 安装依赖 +(不运行生命周期脚本)。请保持 hook 包依赖树为“纯 JS/TS”,并避免依赖 `postinstall` 构建的包。 ## Hook 结构 ### HOOK.md 格式 -`HOOK.md` 文件在 YAML frontmatter 中包含元数据,加上 Markdown 文档: +`HOOK.md` 文件包含 YAML frontmatter 中的元数据以及 Markdown 文档: ```markdown --- name: my-hook -description: "Short description of what this hook does" +description: "关于此 hook 功能的简短描述" homepage: https://docs.openclaw.ai/automation/hooks#my-hook metadata: { "openclaw": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } } @@ -140,49 +150,47 @@ metadata: # My Hook -Detailed documentation goes here... +详细文档写在这里…… -## What It Does +## 它的作用 -- Listens for `/new` commands -- Performs some action -- Logs the result +- 监听 `/new` 命令 +- 执行某些操作 +- 记录结果 -## Requirements +## 要求 -- Node.js must be installed +- 必须安装 Node.js -## Configuration +## 配置 -No configuration needed. +无需配置。 ``` ### 元数据字段 `metadata.openclaw` 对象支持: -- **`emoji`**:CLI 的显示表情符号(例如 `"💾"`) +- **`emoji`**:CLI 显示用 emoji(例如 `"💾"`) - **`events`**:要监听的事件数组(例如 `["command:new", "command:reset"]`) - **`export`**:要使用的命名导出(默认为 `"default"`) - **`homepage`**:文档 URL - **`requires`**:可选要求 - - **`bins`**:PATH 中需要的二进制文件(例如 `["git", "node"]`) - - **`anyBins`**:这些二进制文件中至少有一个必须存在 - - **`env`**:需要的环境变量 - - **`config`**:需要的配置路径(例如 `["workspace.dir"]`) - - **`os`**:需要的平台(例如 `["darwin", "linux"]`) + - **`bins`**:PATH 中必须存在的二进制文件(例如 `["git", "node"]`) + - **`anyBins`**:这些二进制文件中至少要存在一个 + - **`env`**:必需的环境变量 + - **`config`**:必需的配置路径(例如 `["workspace.dir"]`) + - **`os`**:支持的平台(例如 `["darwin", "linux"]`) - **`always`**:绕过资格检查(布尔值) -- **`install`**:安装方法(对于捆绑 hooks:`[{"id":"bundled","kind":"bundled"}]`) +- **`install`**:安装方式(对于内置 hooks:`[{"id":"bundled","kind":"bundled"}]`) -### 处理程序实现 +### 处理器实现 -`handler.ts` 文件导出一个 `HookHandler` 函数: +`handler.ts` 文件会导出一个 `HookHandler` 函数: ```typescript -import type { HookHandler } from "../../src/hooks/hooks.js"; - -const myHandler: HookHandler = async (event) => { - // Only trigger on 'new' command +const myHandler = async (event) => { + // 仅在 'new' 命令时触发 if (event.type !== "command" || event.action !== "new") { return; } @@ -191,9 +199,9 @@ const myHandler: HookHandler = async (event) => { console.log(` Session: ${event.sessionKey}`); console.log(` Timestamp: ${event.timestamp.toISOString()}`); - // Your custom logic here + // 你的自定义逻辑写在这里 - // Optionally send message to user + // 可选:向用户发送消息 event.messages.push("✨ My hook executed!"); }; @@ -202,24 +210,31 @@ export default myHandler; #### 事件上下文 -每个事件包含: +每个事件都包含: ```typescript { - type: 'command' | 'session' | 'agent' | 'gateway', - action: string, // e.g., 'new', 'reset', 'stop' - sessionKey: string, // Session identifier - timestamp: Date, // When the event occurred - messages: string[], // Push messages here to send to user + type: 'command' | 'session' | 'agent' | 'gateway' | 'message', + action: string, // 例如 'new'、'reset'、'stop'、'received'、'sent' + sessionKey: string, // 会话标识符 + timestamp: Date, // 事件发生时间 + messages: string[], // 将消息推入这里以发送给用户 context: { + // 命令事件: sessionEntry?: SessionEntry, sessionId?: string, sessionFile?: string, - commandSource?: string, // e.g., 'whatsapp', 'telegram' + commandSource?: string, // 例如 'whatsapp'、'telegram' senderId?: string, workspaceDir?: string, bootstrapFiles?: WorkspaceBootstrapFile[], - cfg?: OpenClawConfig + cfg?: OpenClawConfig, + // 消息事件(完整详情见“消息事件”部分): + from?: string, // message:received + to?: string, // message:sent + content?: string, + channelId?: string, + success?: boolean, // message:sent } } ``` @@ -228,28 +243,135 @@ export default myHandler; ### 命令事件 -当发出智能体命令时触发: +在发出智能体命令时触发: - **`command`**:所有命令事件(通用监听器) -- **`command:new`**:当发出 `/new` 命令时 -- **`command:reset`**:当发出 `/reset` 命令时 -- **`command:stop`**:当发出 `/stop` 命令时 +- **`command:new`**:发出 `/new` 命令时 +- **`command:reset`**:发出 `/reset` 命令时 +- **`command:stop`**:发出 `/stop` 命令时 + +### 会话事件 + +- **`session:compact:before`**:在压缩开始总结历史记录之前 +- **`session:compact:after`**:在压缩完成并带有摘要元数据之后 + +内部 hook 负载会将这些事件表示为 `type: "session"`,并将 `action` 设为 `"compact:before"` / `"compact:after"`;监听器使用上面的组合键进行订阅。 +具体处理器注册使用字面量键格式 `${type}:${action}`。对于这些事件,请注册 `session:compact:before` 和 `session:compact:after`。 ### 智能体事件 -- **`agent:bootstrap`**:在注入工作区引导文件之前(hooks 可以修改 `context.bootstrapFiles`) +- **`agent:bootstrap`**:在工作区引导文件被注入之前(hooks 可以修改 `context.bootstrapFiles`) ### Gateway 网关事件 -当 Gateway 网关启动时触发: +在 Gateway 网关启动时触发: -- **`gateway:startup`**:在渠道启动和 hooks 加载之后 +- **`gateway:startup`**:在渠道启动且 hooks 已加载之后 + +### 消息事件 + +在消息被接收或发送时触发: + +- **`message`**:所有消息事件(通用监听器) +- **`message:received`**:当从任意渠道收到入站消息时。在处理的早期阶段触发,此时媒体理解尚未完成。对于尚未处理的媒体附件,内容中可能包含类似 `` 的原始占位符。 +- **`message:transcribed`**:当一条消息已被完全处理,包括音频转写和链接理解时触发。此时,`transcript` 包含音频消息的完整转写文本。当你需要访问已转写的音频内容时,请使用此 hook。 +- **`message:preprocessed`**:在所有媒体 + 链接理解完成后,为每条消息触发,使 hooks 可以在智能体看到消息之前访问完全增强的正文(转写、图像描述、链接摘要)。 +- **`message:sent`**:当出站消息成功发送时 + +#### 消息事件上下文 + +消息事件包含关于消息的丰富上下文: + +```typescript +// message:received context +{ + from: string, // 发送者标识符(电话号码、用户 ID 等) + content: string, // 消息内容 + timestamp?: number, // 接收时的 Unix 时间戳 + channelId: string, // 渠道(例如 "whatsapp"、"telegram"、"discord") + accountId?: string, // 多账号设置中的提供商账号 ID + conversationId?: string, // 聊天/会话 ID + messageId?: string, // 提供商返回的消息 ID + metadata?: { // 额外的提供商特定数据 + to?: string, + provider?: string, + surface?: string, + threadId?: string, + senderId?: string, + senderName?: string, + senderUsername?: string, + senderE164?: string, + } +} + +// message:sent context +{ + to: string, // 接收者标识符 + content: string, // 已发送的消息内容 + success: boolean, // 发送是否成功 + error?: string, // 如果发送失败,则为错误消息 + channelId: string, // 渠道(例如 "whatsapp"、"telegram"、"discord") + accountId?: string, // 提供商账号 ID + conversationId?: string, // 聊天/会话 ID + messageId?: string, // 提供商返回的消息 ID + isGroup?: boolean, // 此出站消息是否属于群组/渠道上下文 + groupId?: string, // 用于与 message:received 关联的群组/渠道标识符 +} + +// message:transcribed context +{ + body?: string, // 增强前的原始入站正文 + bodyForAgent?: string, // 对智能体可见的增强正文 + transcript: string, // 音频转写文本 + channelId: string, // 渠道(例如 "telegram"、"whatsapp") + conversationId?: string, + messageId?: string, +} + +// message:preprocessed context +{ + body?: string, // 原始入站正文 + bodyForAgent?: string, // 媒体/链接理解后的最终增强正文 + transcript?: string, // 存在音频时的转写内容 + channelId: string, // 渠道(例如 "telegram"、"whatsapp") + conversationId?: string, + messageId?: string, + isGroup?: boolean, + groupId?: string, +} +``` + +#### 示例:消息记录器 Hook + +```typescript +const isMessageReceivedEvent = (event: { type: string; action: string }) => + event.type === "message" && event.action === "received"; +const isMessageSentEvent = (event: { type: string; action: string }) => + event.type === "message" && event.action === "sent"; + +const handler = async (event) => { + if (isMessageReceivedEvent(event as { type: string; action: string })) { + console.log(`[message-logger] Received from ${event.context.from}: ${event.context.content}`); + } else if (isMessageSentEvent(event as { type: string; action: string })) { + console.log(`[message-logger] Sent to ${event.context.to}: ${event.context.content}`); + } +}; + +export default handler; +``` ### 工具结果 Hooks(插件 API) -这些 hooks 不是事件流监听器;它们让插件在 OpenClaw 持久化工具结果之前同步调整它们。 +这些 hooks 不是事件流监听器;它们允许插件在 OpenClaw 持久化工具结果之前同步调整工具结果。 -- **`tool_result_persist`**:在工具结果写入会话记录之前转换它们。必须是同步的;返回更新后的工具结果负载或 `undefined` 保持原样。参见 [智能体循环](/concepts/agent-loop)。 +- **`tool_result_persist`**:在工具结果写入会话转录之前对其进行转换。必须是同步的;返回更新后的工具结果负载,或返回 `undefined` 以保持原样。请参阅 [Agent Loop](/concepts/agent-loop)。 + +### 插件 Hook 事件 + +通过插件 hook 运行器公开的压缩生命周期 hooks: + +- **`before_compaction`**:在压缩前运行,并带有计数/token 元数据 +- **`after_compaction`**:在压缩后运行,并带有压缩摘要元数据 ### 未来事件 @@ -258,14 +380,12 @@ export default myHandler; - **`session:start`**:当新会话开始时 - **`session:end`**:当会话结束时 - **`agent:error`**:当智能体遇到错误时 -- **`message:sent`**:当消息被发送时 -- **`message:received`**:当消息被接收时 ## 创建自定义 Hooks ### 1. 选择位置 -- **工作区 hooks**(`/hooks/`):每智能体,最高优先级 +- **工作区 hooks**(`/hooks/`):每个智能体单独配置,优先级最高 - **托管 hooks**(`~/.openclaw/hooks/`):跨工作区共享 ### 2. 创建目录结构 @@ -280,27 +400,25 @@ cd ~/.openclaw/hooks/my-hook ```markdown --- name: my-hook -description: "Does something useful" +description: "执行某些有用的事情" metadata: { "openclaw": { "emoji": "🎯", "events": ["command:new"] } } --- # My Custom Hook -This hook does something useful when you issue `/new`. +当你发出 `/new` 时,此 hook 会执行一些有用的事情。 ``` ### 4. 创建 handler.ts ```typescript -import type { HookHandler } from "../../src/hooks/hooks.js"; - -const handler: HookHandler = async (event) => { +const handler = async (event) => { if (event.type !== "command" || event.action !== "new") { return; } console.log("[my-hook] Running!"); - // Your logic here + // 你的逻辑写在这里 }; export default handler; @@ -309,16 +427,16 @@ export default handler; ### 5. 启用并测试 ```bash -# Verify hook is discovered +# 验证 hook 已被发现 openclaw hooks list -# Enable it +# 启用它 openclaw hooks enable my-hook -# Restart your gateway process (menu bar app restart on macOS, or restart your dev process) +# 重启你的 Gateway 网关进程(macOS 上重启菜单栏应用,或重启你的开发进程) -# Trigger the event -# Send /new via your messaging channel +# 触发事件 +# 通过你的消息渠道发送 /new ``` ## 配置 @@ -339,9 +457,9 @@ openclaw hooks enable my-hook } ``` -### 每 Hook 配置 +### 每个 Hook 的配置 -Hooks 可以有自定义配置: +Hooks 可以具有自定义配置: ```json { @@ -378,9 +496,9 @@ Hooks 可以有自定义配置: } ``` -### 遗留配置格式(仍然支持) +### 旧版配置格式(仍受支持) -旧配置格式仍然有效以保持向后兼容: +旧配置格式仍可用于向后兼容: ```json { @@ -399,74 +517,76 @@ Hooks 可以有自定义配置: } ``` -**迁移**:对新 hooks 使用基于发现的新系统。遗留处理程序在基于目录的 hooks 之后加载。 +注意:`module` 必须是相对于工作区的路径。绝对路径和超出工作区范围的遍历路径会被拒绝。 + +**迁移**:对于新的 hooks,请使用基于发现的新系统。旧版 handlers 会在基于目录的 hooks 之后加载。 ## CLI 命令 ### 列出 Hooks ```bash -# List all hooks +# 列出所有 hooks openclaw hooks list -# Show only eligible hooks +# 仅显示符合条件的 hooks openclaw hooks list --eligible -# Verbose output (show missing requirements) +# 详细输出(显示缺失的要求) openclaw hooks list --verbose -# JSON output +# JSON 输出 openclaw hooks list --json ``` ### Hook 信息 ```bash -# Show detailed info about a hook +# 显示某个 hook 的详细信息 openclaw hooks info session-memory -# JSON output +# JSON 输出 openclaw hooks info session-memory --json ``` ### 检查资格 ```bash -# Show eligibility summary +# 显示资格摘要 openclaw hooks check -# JSON output +# JSON 输出 openclaw hooks check --json ``` ### 启用/禁用 ```bash -# Enable a hook +# 启用一个 hook openclaw hooks enable session-memory -# Disable a hook +# 禁用一个 hook openclaw hooks disable command-logger ``` -## 捆绑的 Hooks +## 内置 hook 参考 ### session-memory -当你发出 `/new` 时将会话上下文保存到记忆。 +当你发出 `/new` 时,将会话上下文保存到 memory。 **事件**:`command:new` **要求**:必须配置 `workspace.dir` -**输出**:`/memory/YYYY-MM-DD-slug.md`(默认为 `~/.openclaw/workspace`) +**输出**:`/memory/YYYY-MM-DD-slug.md`(默认是 `~/.openclaw/workspace`) -**功能**: +**它的作用**: -1. 使用预重置会话条目定位正确的记录 -2. 提取最后 15 行对话 -3. 使用 LLM 生成描述性文件名 slug -4. 将会话元数据保存到带日期的记忆文件 +1. 使用重置前的会话条目定位正确的转录 +2. 提取最近 15 行对话 +3. 使用 LLM 生成描述性的文件名 slug +4. 将会话元数据保存到带日期的 memory 文件中 **示例输出**: @@ -482,7 +602,7 @@ openclaw hooks disable command-logger - `2026-01-16-vendor-pitch.md` - `2026-01-16-api-design.md` -- `2026-01-16-1430.md`(如果 slug 生成失败则回退到时间戳) +- `2026-01-16-1430.md`(如果 slug 生成失败,则回退为时间戳) **启用**: @@ -490,9 +610,50 @@ openclaw hooks disable command-logger openclaw hooks enable session-memory ``` +### bootstrap-extra-files + +在 `agent:bootstrap` 期间注入额外的引导文件(例如 monorepo 本地的 `AGENTS.md` / `TOOLS.md`)。 + +**事件**:`agent:bootstrap` + +**要求**:必须配置 `workspace.dir` + +**输出**:不写入文件;仅在内存中修改引导上下文。 + +**配置**: + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "entries": { + "bootstrap-extra-files": { + "enabled": true, + "paths": ["packages/*/AGENTS.md", "packages/*/TOOLS.md"] + } + } + } + } +} +``` + +**说明**: + +- 路径相对于工作区解析。 +- 文件必须保持在工作区内部(通过 realpath 检查)。 +- 仅加载已识别的引导基础文件名。 +- 会保留子智能体允许列表(仅 `AGENTS.md` 和 `TOOLS.md`)。 + +**启用**: + +```bash +openclaw hooks enable bootstrap-extra-files +``` + ### command-logger -将所有命令事件记录到集中审计文件。 +将所有命令事件记录到集中式审计文件。 **事件**:`command` @@ -500,10 +661,10 @@ openclaw hooks enable session-memory **输出**:`~/.openclaw/logs/commands.log` -**功能**: +**它的作用**: 1. 捕获事件详情(命令操作、时间戳、会话键、发送者 ID、来源) -2. 以 JSONL 格式追加到日志文件 +2. 以 JSONL 格式附加到日志文件 3. 在后台静默运行 **示例日志条目**: @@ -516,13 +677,13 @@ openclaw hooks enable session-memory **查看日志**: ```bash -# View recent commands +# 查看最近的命令 tail -n 20 ~/.openclaw/logs/commands.log -# Pretty-print with jq +# 使用 jq 美化输出 cat ~/.openclaw/logs/commands.log | jq . -# Filter by action +# 按操作筛选 grep '"action":"new"' ~/.openclaw/logs/commands.log | jq . ``` @@ -534,18 +695,18 @@ openclaw hooks enable command-logger ### boot-md -当 Gateway 网关启动时运行 `BOOT.md`(在渠道启动之后)。 -必须启用内部 hooks 才能运行。 +当 Gateway 网关启动时(渠道启动之后)运行 `BOOT.md`。 +必须启用内部 hooks,此功能才会运行。 **事件**:`gateway:startup` **要求**:必须配置 `workspace.dir` -**功能**: +**它的作用**: 1. 从你的工作区读取 `BOOT.md` -2. 通过智能体运行器运行指令 -3. 通过 message 工具发送任何请求的出站消息 +2. 通过智能体运行器执行其中的指令 +3. 通过消息工具发送任何请求的出站消息 **启用**: @@ -555,26 +716,26 @@ openclaw hooks enable boot-md ## 最佳实践 -### 保持处理程序快速 +### 保持处理器快速 -Hooks 在命令处理期间运行。保持它们轻量: +Hooks 在命令处理期间运行。请保持其轻量: ```typescript -// ✓ Good - async work, returns immediately +// ✓ 好 - 异步工作,立即返回 const handler: HookHandler = async (event) => { - void processInBackground(event); // Fire and forget + void processInBackground(event); // 触发后不等待 }; -// ✗ Bad - blocks command processing +// ✗ 差 - 阻塞命令处理 const handler: HookHandler = async (event) => { await slowDatabaseQuery(event); await evenSlowerAPICall(event); }; ``` -### 优雅处理错误 +### 优雅地处理错误 -始终包装有风险的操作: +始终包装高风险操作: ```typescript const handler: HookHandler = async (event) => { @@ -582,112 +743,117 @@ const handler: HookHandler = async (event) => { await riskyOperation(event); } catch (err) { console.error("[my-handler] Failed:", err instanceof Error ? err.message : String(err)); - // Don't throw - let other handlers run + // 不要抛出错误 - 让其他处理器继续运行 } }; ``` ### 尽早过滤事件 -如果事件不相关则尽早返回: +如果事件不相关,请尽早返回: ```typescript const handler: HookHandler = async (event) => { - // Only handle 'new' commands + // 仅处理 'new' 命令 if (event.type !== "command" || event.action !== "new") { return; } - // Your logic here + // 你的逻辑写在这里 }; ``` -### 使用特定事件键 +### 使用具体事件键 -尽可能在元数据中指定确切事件: +如果可能,请在元数据中指定精确事件: ```yaml -metadata: { "openclaw": { "events": ["command:new"] } } # Specific +metadata: { "openclaw": { "events": ["command:new"] } } # 精确 ``` 而不是: ```yaml -metadata: { "openclaw": { "events": ["command"] } } # General - more overhead +metadata: { "openclaw": { "events": ["command"] } } # 通用 - 开销更大 ``` ## 调试 ### 启用 Hook 日志 -Gateway 网关在启动时记录 hook 加载: +Gateway 网关会在启动时记录 hook 加载情况: ``` Registered hook: session-memory -> command:new +Registered hook: bootstrap-extra-files -> agent:bootstrap Registered hook: command-logger -> command Registered hook: boot-md -> gateway:startup ``` -### 检查发现 +### 检查发现情况 -列出所有发现的 hooks: +列出所有已发现的 hooks: ```bash openclaw hooks list --verbose ``` -### 检查注册 +### 检查注册情况 -在你的处理程序中,记录它被调用的时间: +在你的处理器中,记录它何时被调用: ```typescript const handler: HookHandler = async (event) => { console.log("[my-handler] Triggered:", event.type, event.action); - // Your logic + // 你的逻辑 }; ``` ### 验证资格 -检查为什么 hook 不符合条件: +检查某个 hook 为什么不符合条件: ```bash openclaw hooks info my-hook ``` -在输出中查找缺失的要求。 +查看输出中缺失的要求。 ## 测试 ### Gateway 网关日志 -监控 Gateway 网关日志以查看 hook 执行: +监控 Gateway 网关日志以查看 hook 执行情况: ```bash # macOS ./scripts/clawlog.sh -f -# Other platforms +# 其他平台 tail -f ~/.openclaw/gateway.log ``` ### 直接测试 Hooks -隔离测试你的处理程序: +单独测试你的 handlers: ```typescript import { test } from "vitest"; -import { createHookEvent } from "./src/hooks/hooks.js"; import myHandler from "./hooks/my-hook/handler.js"; test("my handler works", async () => { - const event = createHookEvent("command", "new", "test-session", { - foo: "bar", - }); + const event = { + type: "command", + action: "new", + sessionKey: "test-session", + timestamp: new Date(), + messages: [], + context: { foo: "bar" }, + }; await myHandler(event); - // Assert side effects + // 断言副作用 }); ``` @@ -696,8 +862,8 @@ test("my handler works", async () => { ### 核心组件 - **`src/hooks/types.ts`**:类型定义 -- **`src/hooks/workspace.ts`**:目录扫描和加载 -- **`src/hooks/frontmatter.ts`**:HOOK.md 元数据解析 +- **`src/hooks/workspace.ts`**:目录扫描与加载 +- **`src/hooks/frontmatter.ts`**:`HOOK.md` 元数据解析 - **`src/hooks/config.ts`**:资格检查 - **`src/hooks/hooks-status.ts`**:状态报告 - **`src/hooks/loader.ts`**:动态模块加载器 @@ -710,15 +876,15 @@ test("my handler works", async () => { ``` Gateway 网关启动 ↓ -扫描目录(工作区 → 托管 → 捆绑) +扫描目录(工作区 → 托管 → 内置) ↓ 解析 HOOK.md 文件 ↓ 检查资格(bins、env、config、os) ↓ -从符合条件的 hooks 加载处理程序 +从符合条件的 hooks 加载 handlers ↓ -为事件注册处理程序 +为事件注册 handlers ``` ### 事件流程 @@ -726,11 +892,11 @@ Gateway 网关启动 ``` 用户发送 /new ↓ -命令验证 +命令校验 ↓ 创建 hook 事件 ↓ -触发 hook(所有注册的处理程序) +触发 hook(所有已注册的 handlers) ↓ 命令处理继续 ↓ @@ -745,17 +911,18 @@ Gateway 网关启动 ```bash ls -la ~/.openclaw/hooks/my-hook/ - # Should show: HOOK.md, handler.ts + # 应显示:HOOK.md, handler.ts ``` 2. 验证 HOOK.md 格式: ```bash cat ~/.openclaw/hooks/my-hook/HOOK.md - # Should have YAML frontmatter with name and metadata + # 应包含带有 name 和 metadata 的 YAML frontmatter ``` -3. 列出所有发现的 hooks: +3. 列出所有已发现的 hooks: + ```bash openclaw hooks list ``` @@ -768,12 +935,12 @@ Gateway 网关启动 openclaw hooks info my-hook ``` -查找缺失的: +查看是否缺少: - 二进制文件(检查 PATH) - 环境变量 - 配置值 -- 操作系统兼容性 +- OS 兼容性 ### Hook 未执行 @@ -781,30 +948,31 @@ openclaw hooks info my-hook ```bash openclaw hooks list - # Should show ✓ next to enabled hooks + # 应在已启用的 hooks 旁显示 ✓ ``` 2. 重启你的 Gateway 网关进程以重新加载 hooks。 3. 检查 Gateway 网关日志中的错误: + ```bash ./scripts/clawlog.sh | grep hook ``` -### 处理程序错误 +### 处理器错误 检查 TypeScript/import 错误: ```bash -# Test import directly +# 直接测试导入 node -e "import('./path/to/handler.ts').then(console.log)" ``` ## 迁移指南 -### 从遗留配置到发现 +### 从旧版配置迁移到发现机制 -**之前**: +**迁移前**: ```json { @@ -822,7 +990,7 @@ node -e "import('./path/to/handler.ts').then(console.log)" } ``` -**之后**: +**迁移后**: 1. 创建 hook 目录: @@ -836,13 +1004,13 @@ node -e "import('./path/to/handler.ts').then(console.log)" ```markdown --- name: my-hook - description: "My custom hook" + description: "我的自定义 hook" metadata: { "openclaw": { "emoji": "🎯", "events": ["command:new"] } } --- # My Hook - Does something useful. + 执行某些有用的事情。 ``` 3. 更新配置: @@ -861,9 +1029,10 @@ node -e "import('./path/to/handler.ts').then(console.log)" ``` 4. 验证并重启你的 Gateway 网关进程: + ```bash openclaw hooks list - # Should show: 🎯 my-hook ✓ + # 应显示:🎯 my-hook ✓ ``` **迁移的好处**: @@ -876,7 +1045,7 @@ node -e "import('./path/to/handler.ts').then(console.log)" ## 另请参阅 -- [CLI 参考:hooks](/cli/hooks) -- [捆绑 Hooks README](https://github.com/openclaw/openclaw/tree/main/src/hooks/bundled) +- [CLI Reference: hooks](/cli/hooks) +- [Bundled Hooks README](https://github.com/openclaw/openclaw/tree/main/src/hooks/bundled) - [Webhook Hooks](/automation/webhook) -- [配置](/gateway/configuration#hooks) +- [Configuration](/gateway/configuration#hooks) diff --git a/docs/zh-CN/channels/bluebubbles.md b/docs/zh-CN/channels/bluebubbles.md index 4ee4cb71e3e..b7f8534065b 100644 --- a/docs/zh-CN/channels/bluebubbles.md +++ b/docs/zh-CN/channels/bluebubbles.md @@ -6,34 +6,35 @@ read_when: summary: 通过 BlueBubbles macOS 服务器使用 iMessage(REST 发送/接收、输入状态、回应、配对、高级操作)。 title: BlueBubbles x-i18n: - generated_at: "2026-02-03T10:04:52Z" - model: claude-opus-4-5 - provider: pi - source_hash: 3aae277a8bec479800a7f6268bfbca912c65a4aadc6e513694057fb873597b69 + generated_at: "2026-03-16T06:21:08Z" + model: gpt-5.4 + provider: openai + source_hash: 877592bf7b9b06abdddd7567d56e756eff229d6ffa5056ef33fa3356086aa580 source_path: channels/bluebubbles.md workflow: 15 --- # BlueBubbles(macOS REST) -状态:内置插件,通过 HTTP 与 BlueBubbles macOS 服务器通信。由于其更丰富的 API 和更简便的设置,**推荐用于 iMessage 集成**,优于旧版 imsg 渠道。 +状态:内置插件,通过 HTTP 与 BlueBubbles macOS 服务器通信。由于其 API 更丰富且设置比旧版 imsg 渠道更简单,**推荐用于 iMessage 集成**。 -## 概述 +## 概览 - 通过 BlueBubbles 辅助应用在 macOS 上运行([bluebubbles.app](https://bluebubbles.app))。 -- 推荐/已测试版本:macOS Sequoia (15)。macOS Tahoe (26) 可用;但在 Tahoe 上编辑功能目前不可用,群组图标更新可能显示成功但实际未同步。 -- OpenClaw 通过其 REST API 与之通信(`GET /api/v1/ping`、`POST /message/text`、`POST /chat/:id/*`)。 -- 传入消息通过 webhook 到达;发出的回复、输入指示器、已读回执和 tapback 均为 REST 调用。 -- 附件和贴纸作为入站媒体被接收(并在可能时呈现给智能体)。 -- 配对/白名单的工作方式与其他渠道相同(`/channels/pairing` 等),使用 `channels.bluebubbles.allowFrom` + 配对码。 -- 回应作为系统事件呈现,与 Slack/Telegram 类似,智能体可以在回复前"提及"它们。 +- 推荐/已测试:macOS Sequoia(15)。macOS Tahoe(26)可用;目前编辑功能在 Tahoe 上已损坏,群组图标更新可能会报告成功但不会同步。 +- OpenClaw 通过其 REST API 与其通信(`GET /api/v1/ping`、`POST /message/text`、`POST /chat/:id/*`)。 +- 传入消息通过 webhook 到达;传出回复、输入状态指示、已读回执和 tapback 都通过 REST 调用完成。 +- 附件和贴纸会作为入站媒体接收(并在可能时展示给智能体)。 +- 配对/allowlist 的工作方式与其他渠道相同(`/channels/pairing` 等),使用 `channels.bluebubbles.allowFrom` + 配对码。 +- 回应会像 Slack/Telegram 一样作为系统事件呈现,因此智能体可以在回复前“提及”它们。 - 高级功能:编辑、撤回、回复线程、消息效果、群组管理。 ## 快速开始 -1. 在你的 Mac 上安装 BlueBubbles 服务器(按照 [bluebubbles.app/install](https://bluebubbles.app/install) 的说明操作)。 -2. 在 BlueBubbles 配置中,启用 web API 并设置密码。 +1. 在你的 Mac 上安装 BlueBubbles 服务器(按照 [bluebubbles.app/install](https://bluebubbles.app/install) 上的说明操作)。 +2. 在 BlueBubbles 配置中启用 Web API 并设置密码。 3. 运行 `openclaw onboard` 并选择 BlueBubbles,或手动配置: + ```json5 { channels: { @@ -46,8 +47,89 @@ x-i18n: }, } ``` + 4. 将 BlueBubbles webhook 指向你的 Gateway 网关(示例:`https://your-gateway-host:3000/bluebubbles-webhook?password=`)。 -5. 启动 Gateway 网关;它将注册 webhook 处理程序并开始配对。 +5. 启动 Gateway 网关;它会注册 webhook 处理器并开始配对。 + +安全说明: + +- 始终设置 webhook 密码。 +- 始终要求进行 webhook 身份验证。无论 loopback/代理拓扑如何,除非 BlueBubbles webhook 请求包含与 `channels.bluebubbles.password` 匹配的 password/guid(例如 `?password=` 或 `x-password`),否则 OpenClaw 会拒绝该请求。 +- 在读取/解析完整 webhook 请求体之前就会检查密码身份验证。 + +## 保持 Messages.app 处于活动状态(VM / 无头设置) + +某些 macOS VM / 常开设置可能会导致 Messages.app 进入“空闲”状态(传入事件会停止,直到应用被打开/切到前台)。一个简单的解决方法是使用 AppleScript + LaunchAgent **每 5 分钟“触碰”一次 Messages**。 + +### 1)保存 AppleScript + +将以下内容保存为: + +- `~/Scripts/poke-messages.scpt` + +示例脚本(非交互式;不会抢占焦点): + +```applescript +try + tell application "Messages" + if not running then + launch + end if + + -- Touch the scripting interface to keep the process responsive. + set _chatCount to (count of chats) + end tell +on error + -- Ignore transient failures (first-run prompts, locked session, etc). +end try +``` + +### 2)安装 LaunchAgent + +将以下内容保存为: + +- `~/Library/LaunchAgents/com.user.poke-messages.plist` + +```xml + + + + + Label + com.user.poke-messages + + ProgramArguments + + /bin/bash + -lc + /usr/bin/osascript "$HOME/Scripts/poke-messages.scpt" + + + RunAtLoad + + + StartInterval + 300 + + StandardOutPath + /tmp/poke-messages.log + StandardErrorPath + /tmp/poke-messages.err + + +``` + +说明: + +- 这会**每 300 秒**运行一次,并且**在登录时**运行。 +- 首次运行可能会触发 macOS 的**自动化**权限提示(`osascript` → Messages)。请在运行该 LaunchAgent 的同一用户会话中批准这些提示。 + +加载它: + +```bash +launchctl unload ~/Library/LaunchAgents/com.user.poke-messages.plist 2>/dev/null || true +launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist +``` ## 新手引导 @@ -60,10 +142,10 @@ openclaw onboard 向导会提示输入: - **服务器 URL**(必填):BlueBubbles 服务器地址(例如 `http://192.168.1.100:1234`) -- **密码**(必填):来自 BlueBubbles 服务器设置的 API 密码 +- **密码**(必填):来自 BlueBubbles Server 设置的 API 密码 - **Webhook 路径**(可选):默认为 `/bluebubbles-webhook` -- **私信策略**:配对、白名单、开放或禁用 -- **白名单**:电话号码、电子邮件或聊天目标 +- **私信策略**:pairing、allowlist、open 或 disabled +- **允许列表**:电话号码、电子邮件或聊天目标 你也可以通过 CLI 添加 BlueBubbles: @@ -75,12 +157,12 @@ openclaw channels add bluebubbles --http-url http://192.168.1.100:1234 --passwor 私信: -- 默认:`channels.bluebubbles.dmPolicy = "pairing"`。 -- 未知发送者会收到配对码;在批准之前消息会被忽略(配对码 1 小时后过期)。 -- 批准方式: +- 默认值:`channels.bluebubbles.dmPolicy = "pairing"`。 +- 未知发送者会收到一个配对码;在获得批准前,消息会被忽略(代码 1 小时后过期)。 +- 通过以下方式批准: - `openclaw pairing list bluebubbles` - `openclaw pairing approve bluebubbles ` -- 配对是默认的令牌交换方式。详情:[配对](/channels/pairing) +- 配对是默认的令牌交换方式。详情见:[配对](/channels/pairing) 群组: @@ -89,13 +171,13 @@ openclaw channels add bluebubbles --http-url http://192.168.1.100:1234 --passwor ### 提及门控(群组) -BlueBubbles 支持群聊的提及门控,与 iMessage/WhatsApp 行为一致: +BlueBubbles 支持群聊中的提及门控,与 iMessage/WhatsApp 的行为一致: - 使用 `agents.list[].groupChat.mentionPatterns`(或 `messages.groupChat.mentionPatterns`)检测提及。 -- 当群组启用 `requireMention` 时,智能体仅在被提及时响应。 +- 当某个群组启用 `requireMention` 时,智能体只有在被提及时才会响应。 - 来自授权发送者的控制命令会绕过提及门控。 -单群组配置: +每群组配置: ```json5 { @@ -104,8 +186,8 @@ BlueBubbles 支持群聊的提及门控,与 iMessage/WhatsApp 行为一致: groupPolicy: "allowlist", groupAllowFrom: ["+15555550123"], groups: { - "*": { requireMention: true }, // 所有群组的默认设置 - "iMessage;-;chat123": { requireMention: false }, // 特定群组的覆盖设置 + "*": { requireMention: true }, // 所有群组的默认值 + "iMessage;-;chat123": { requireMention: false }, // 对特定群组覆盖 }, }, }, @@ -115,14 +197,14 @@ BlueBubbles 支持群聊的提及门控,与 iMessage/WhatsApp 行为一致: ### 命令门控 - 控制命令(例如 `/config`、`/model`)需要授权。 -- 使用 `allowFrom` 和 `groupAllowFrom` 确定命令授权。 -- 授权发送者即使在群组中未被提及也可以运行控制命令。 +- 使用 `allowFrom` 和 `groupAllowFrom` 来判断命令授权。 +- 授权发送者即使在群组中未提及,也可以运行控制命令。 ## 输入状态 + 已读回执 -- **输入指示器**:在响应生成前和生成期间自动发送。 +- **输入状态指示**:会在生成响应之前和期间自动发送。 - **已读回执**:由 `channels.bluebubbles.sendReadReceipts` 控制(默认:`true`)。 -- **输入指示器**:OpenClaw 发送输入开始事件;BlueBubbles 在发送或超时时自动清除输入状态(通过 DELETE 手动停止不可靠)。 +- **输入状态指示**:OpenClaw 会发送输入开始事件;BlueBubbles 会在发送后或超时后自动清除输入状态(通过 DELETE 手动停止并不可靠)。 ```json5 { @@ -136,7 +218,7 @@ BlueBubbles 支持群聊的提及门控,与 iMessage/WhatsApp 行为一致: ## 高级操作 -BlueBubbles 在配置中启用时支持高级消息操作: +在配置中启用后,BlueBubbles 支持高级消息操作: ```json5 { @@ -144,15 +226,15 @@ BlueBubbles 在配置中启用时支持高级消息操作: bluebubbles: { actions: { reactions: true, // tapback(默认:true) - edit: true, // 编辑已发送消息(macOS 13+,在 macOS 26 Tahoe 上不可用) + edit: true, // 编辑已发送消息(macOS 13+,在 macOS 26 Tahoe 上已损坏) unsend: true, // 撤回消息(macOS 13+) - reply: true, // 通过消息 GUID 进行回复线程 + reply: true, // 按消息 GUID 回复线程 sendWithEffect: true, // 消息效果(slam、loud 等) renameGroup: true, // 重命名群聊 setGroupIcon: true, // 设置群聊图标/照片(在 macOS 26 Tahoe 上不稳定) - addParticipant: true, // 将参与者添加到群组 + addParticipant: true, // 向群组添加参与者 removeParticipant: true, // 从群组移除参与者 - leaveGroup: true, // 离开群聊 + leaveGroup: true, // 退出群聊 sendAttachment: true, // 发送附件/媒体 }, }, @@ -163,37 +245,37 @@ BlueBubbles 在配置中启用时支持高级消息操作: 可用操作: - **react**:添加/移除 tapback 回应(`messageId`、`emoji`、`remove`) -- **edit**:编辑已发送的消息(`messageId`、`text`) +- **edit**:编辑已发送消息(`messageId`、`text`) - **unsend**:撤回消息(`messageId`) - **reply**:回复特定消息(`messageId`、`text`、`to`) - **sendWithEffect**:带 iMessage 效果发送(`text`、`to`、`effectId`) - **renameGroup**:重命名群聊(`chatGuid`、`displayName`) -- **setGroupIcon**:设置群聊图标/照片(`chatGuid`、`media`)— 在 macOS 26 Tahoe 上不稳定(API 可能返回成功但图标未同步)。 -- **addParticipant**:将某人添加到群组(`chatGuid`、`address`) -- **removeParticipant**:将某人从群组移除(`chatGuid`、`address`) -- **leaveGroup**:离开群聊(`chatGuid`) +- **setGroupIcon**:设置群聊图标/照片(`chatGuid`、`media`)——在 macOS 26 Tahoe 上不稳定(API 可能返回成功,但图标不会同步)。 +- **addParticipant**:向群组添加某人(`chatGuid`、`address`) +- **removeParticipant**:从群组移除某人(`chatGuid`、`address`) +- **leaveGroup**:退出群聊(`chatGuid`) - **sendAttachment**:发送媒体/文件(`to`、`buffer`、`filename`、`asVoice`) - - 语音备忘录:将 `asVoice: true` 与 **MP3** 或 **CAF** 音频一起设置,以 iMessage 语音消息形式发送。BlueBubbles 在发送语音备忘录时会将 MP3 转换为 CAF。 + - 语音备忘录:将 `asVoice: true` 与 **MP3** 或 **CAF** 音频一起设置,即可作为 iMessage 语音消息发送。BlueBubbles 在发送语音备忘录时会将 MP3 转换为 CAF。 -### 消息 ID(短格式 vs 完整格式) +### 消息 ID(短 ID 与完整 ID) OpenClaw 可能会显示*短*消息 ID(例如 `1`、`2`)以节省 token。 - `MessageSid` / `ReplyToId` 可以是短 ID。 - `MessageSidFull` / `ReplyToIdFull` 包含提供商的完整 ID。 -- 短 ID 存储在内存中;它们可能在重启或缓存清除后过期。 -- 操作接受短或完整的 `messageId`,但如果短 ID 不再可用将会报错。 +- 短 ID 存在于内存中;它们可能会在重启或缓存清除后失效。 +- 操作接受短或完整 `messageId`,但如果短 ID 不再可用,就会报错。 对于持久化自动化和存储,请使用完整 ID: - 模板:`{{MessageSidFull}}`、`{{ReplyToIdFull}}` - 上下文:入站负载中的 `MessageSidFull` / `ReplyToIdFull` -参见[配置](/gateway/configuration)了解模板变量。 +模板变量请参见[配置](/gateway/configuration)。 ## 分块流式传输 -控制响应是作为单条消息发送还是分块流式传输: +控制响应是作为单条消息发送,还是按块流式发送: ```json5 { @@ -208,8 +290,8 @@ OpenClaw 可能会显示*短*消息 ID(例如 `1`、`2`)以节省 token。 ## 媒体 + 限制 - 入站附件会被下载并存储在媒体缓存中。 -- 媒体上限通过 `channels.bluebubbles.mediaMaxMb` 设置(默认:8 MB)。 -- 出站文本按 `channels.bluebubbles.textChunkLimit` 分块(默认:4000 字符)。 +- 通过 `channels.bluebubbles.mediaMaxMb` 控制入站和出站媒体大小上限(默认:8 MB)。 +- 出站文本会按 `channels.bluebubbles.textChunkLimit` 分块(默认:4000 个字符)。 ## 配置参考 @@ -217,22 +299,23 @@ OpenClaw 可能会显示*短*消息 ID(例如 `1`、`2`)以节省 token。 提供商选项: -- `channels.bluebubbles.enabled`:启用/禁用渠道。 +- `channels.bluebubbles.enabled`:启用/禁用该渠道。 - `channels.bluebubbles.serverUrl`:BlueBubbles REST API 基础 URL。 - `channels.bluebubbles.password`:API 密码。 - `channels.bluebubbles.webhookPath`:Webhook 端点路径(默认:`/bluebubbles-webhook`)。 - `channels.bluebubbles.dmPolicy`:`pairing | allowlist | open | disabled`(默认:`pairing`)。 -- `channels.bluebubbles.allowFrom`:私信白名单(句柄、电子邮件、E.164 号码、`chat_id:*`、`chat_guid:*`)。 +- `channels.bluebubbles.allowFrom`:私信 allowlist(handle、电子邮件、E.164 号码、`chat_id:*`、`chat_guid:*`)。 - `channels.bluebubbles.groupPolicy`:`open | allowlist | disabled`(默认:`allowlist`)。 -- `channels.bluebubbles.groupAllowFrom`:群组发送者白名单。 -- `channels.bluebubbles.groups`:单群组配置(`requireMention` 等)。 +- `channels.bluebubbles.groupAllowFrom`:群组发送者 allowlist。 +- `channels.bluebubbles.groups`:每群组配置(`requireMention` 等)。 - `channels.bluebubbles.sendReadReceipts`:发送已读回执(默认:`true`)。 -- `channels.bluebubbles.blockStreaming`:启用分块流式传输(默认:`false`;流式回复必需)。 -- `channels.bluebubbles.textChunkLimit`:出站分块大小(字符)(默认:4000)。 -- `channels.bluebubbles.chunkMode`:`length`(默认)仅在超过 `textChunkLimit` 时分割;`newline` 在长度分块前先按空行(段落边界)分割。 -- `channels.bluebubbles.mediaMaxMb`:入站媒体上限(MB)(默认:8)。 -- `channels.bluebubbles.historyLimit`:上下文的最大群组消息数(0 表示禁用)。 -- `channels.bluebubbles.dmHistoryLimit`:私信历史限制。 +- `channels.bluebubbles.blockStreaming`:启用分块流式传输(默认:`false`;流式回复所必需)。 +- `channels.bluebubbles.textChunkLimit`:出站分块大小(按字符计,默认:4000)。 +- `channels.bluebubbles.chunkMode`:`length`(默认)仅在超过 `textChunkLimit` 时拆分;`newline` 会先按空行(段落边界)拆分,再按长度分块。 +- `channels.bluebubbles.mediaMaxMb`:入站/出站媒体大小上限(MB,默认:8)。 +- `channels.bluebubbles.mediaLocalRoots`:允许用于出站本地媒体路径的绝对本地目录显式 allowlist。默认情况下,本地路径发送会被拒绝,除非配置了此项。每账户覆盖:`channels.bluebubbles.accounts..mediaLocalRoots`。 +- `channels.bluebubbles.historyLimit`:用于上下文的最大群组消息数(0 表示禁用)。 +- `channels.bluebubbles.dmHistoryLimit`:私信历史记录上限。 - `channels.bluebubbles.actions`:启用/禁用特定操作。 - `channels.bluebubbles.accounts`:多账户配置。 @@ -241,31 +324,31 @@ OpenClaw 可能会显示*短*消息 ID(例如 `1`、`2`)以节省 token。 - `agents.list[].groupChat.mentionPatterns`(或 `messages.groupChat.mentionPatterns`)。 - `messages.responsePrefix`。 -## 地址 / 投递目标 +## 寻址 / 送达目标 -优先使用 `chat_guid` 以获得稳定的路由: +优先使用 `chat_guid` 以获得稳定路由: -- `chat_guid:iMessage;-;+15555550123`(群组推荐) +- `chat_guid:iMessage;-;+15555550123`(群组首选) - `chat_id:123` - `chat_identifier:...` -- 直接句柄:`+15555550123`、`user@example.com` - - 如果直接句柄没有现有的私信聊天,OpenClaw 将通过 `POST /api/v1/chat/new` 创建一个。这需要启用 BlueBubbles Private API。 +- 直接 handle:`+15555550123`、`user@example.com` + - 如果某个直接 handle 没有现有私信聊天,OpenClaw 会通过 `POST /api/v1/chat/new` 创建一个。这要求启用 BlueBubbles Private API。 -## 安全性 +## 安全 -- Webhook 请求通过比较 `guid`/`password` 查询参数或头部与 `channels.bluebubbles.password` 进行身份验证。来自 `localhost` 的请求也会被接受。 -- 保持 API 密码和 webhook 端点的机密性(将它们视为凭证)。 -- localhost 信任意味着同主机的反向代理可能无意中绕过密码验证。如果你使用代理 Gateway 网关,请在代理处要求身份验证并配置 `gateway.trustedProxies`。参见 [Gateway 网关安全性](/gateway/security#reverse-proxy-configuration)。 -- 如果将 BlueBubbles 服务器暴露在局域网之外,请启用 HTTPS + 防火墙规则。 +- 通过将查询参数或请求头中的 `guid`/`password` 与 `channels.bluebubbles.password` 进行比较来验证 webhook 请求。来自 `localhost` 的请求也会被接受。 +- 请妥善保管 API 密码和 webhook 端点(将它们视为凭证)。 +- 对 localhost 的信任意味着同主机上的反向代理可能会无意中绕过密码。如果你对 Gateway 网关进行了代理,请在代理层要求身份验证,并配置 `gateway.trustedProxies`。请参见 [Gateway 网关安全](/gateway/security#reverse-proxy-configuration)。 +- 如果要在局域网外暴露 BlueBubbles 服务器,请启用 HTTPS + 防火墙规则。 ## 故障排除 -- 如果输入/已读事件停止工作,请检查 BlueBubbles webhook 日志并验证 Gateway 网关路径是否与 `channels.bluebubbles.webhookPath` 匹配。 -- 配对码在一小时后过期;使用 `openclaw pairing list bluebubbles` 和 `openclaw pairing approve bluebubbles `。 -- 回应需要 BlueBubbles private API(`POST /api/v1/message/react`);确保服务器版本支持它。 -- 编辑/撤回需要 macOS 13+ 和兼容的 BlueBubbles 服务器版本。在 macOS 26(Tahoe)上,由于 private API 变更,编辑功能目前不可用。 -- 在 macOS 26(Tahoe)上群组图标更新可能不稳定:API 可能返回成功但新图标未同步。 -- OpenClaw 会根据 BlueBubbles 服务器的 macOS 版本自动隐藏已知不可用的操作。如果在 macOS 26(Tahoe)上编辑仍然显示,请使用 `channels.bluebubbles.actions.edit=false` 手动禁用。 -- 查看状态/健康信息:`openclaw status --all` 或 `openclaw status --deep`。 +- 如果输入状态/已读事件停止工作,请检查 BlueBubbles webhook 日志,并验证 Gateway 网关路径与 `channels.bluebubbles.webhookPath` 一致。 +- 配对码会在一小时后过期;使用 `openclaw pairing list bluebubbles` 和 `openclaw pairing approve bluebubbles `。 +- 回应需要 BlueBubbles private API(`POST /api/v1/message/react`);请确保服务器版本提供该接口。 +- 编辑/撤回需要 macOS 13+ 以及兼容的 BlueBubbles 服务器版本。在 macOS 26(Tahoe)上,由于 private API 变更,编辑功能目前已损坏。 +- 群组图标更新在 macOS 26(Tahoe)上可能不稳定:API 可能返回成功,但新图标不会同步。 +- OpenClaw 会根据 BlueBubbles 服务器的 macOS 版本自动隐藏已知损坏的操作。如果在 macOS 26(Tahoe)上仍显示编辑操作,请手动使用 `channels.bluebubbles.actions.edit=false` 禁用它。 +- 状态/健康信息请使用:`openclaw status --all` 或 `openclaw status --deep`。 -有关通用渠道工作流参考,请参阅[渠道](/channels)和[插件](/tools/plugin)指南。 +有关通用渠道工作流程参考,请参见 [Channels](/channels) 和 [Plugins](/tools/plugin) 指南。 diff --git a/docs/zh-CN/channels/feishu.md b/docs/zh-CN/channels/feishu.md index 6a8d8633af9..af561e3a8ea 100644 --- a/docs/zh-CN/channels/feishu.md +++ b/docs/zh-CN/channels/feishu.md @@ -1,22 +1,29 @@ --- -summary: "飞书机器人支持状态、功能和配置" read_when: - - 您想要连接飞书机器人 - - 您正在配置飞书渠道 + - 你想连接一个飞书/Lark 机器人 + - 你正在配置飞书渠道 +summary: 飞书机器人概览、功能和配置 title: 飞书 +x-i18n: + generated_at: "2026-03-16T06:21:11Z" + model: gpt-5.4 + provider: openai + source_hash: 951e78c5c7264471382f863fa896a15ddeaf0717ef782da20d0f1b3eb23396ba + source_path: channels/feishu.md + workflow: 15 --- # 飞书机器人 -状态:生产就绪,支持机器人私聊和群组。使用 WebSocket 长连接模式接收消息。 +飞书(Lark)是企业用于消息沟通与协作的团队聊天平台。此插件通过平台的 WebSocket 事件订阅将 OpenClaw 连接到飞书/Lark 机器人,因此无需暴露公共 webhook URL 即可接收消息。 --- -## 内置插件 +## 捆绑插件 -当前版本的 OpenClaw 已内置 Feishu 插件,因此通常不需要单独安装。 +飞书随当前的 OpenClaw 版本一同捆绑提供,因此无需单独安装插件。 -如果你使用的是较旧版本,或是没有内置 Feishu 的自定义安装,可手动安装: +如果你使用的是较旧版本,或使用了不包含捆绑飞书的自定义安装,请手动安装: ```bash openclaw plugins install @openclaw/feishu @@ -26,75 +33,75 @@ openclaw plugins install @openclaw/feishu ## 快速开始 -添加飞书渠道有两种方式: +有两种方式可添加飞书渠道: -### 方式一:通过安装向导添加(推荐) +### 方法 1:设置向导(推荐) -如果您刚安装完 OpenClaw,可以直接运行向导,根据提示添加飞书: +如果你刚安装 OpenClaw,请运行设置向导: ```bash openclaw onboard ``` -向导会引导您完成: +向导会引导你完成以下步骤: -1. 创建飞书应用并获取凭证 -2. 配置应用凭证 -3. 启动网关 +1. 创建飞书应用并收集凭证 +2. 在 OpenClaw 中配置应用凭证 +3. 启动 Gateway 网关 -✅ **完成配置后**,您可以使用以下命令检查网关状态: +✅ **配置完成后**,检查 Gateway 网关状态: -- `openclaw gateway status` - 查看网关运行状态 -- `openclaw logs --follow` - 查看实时日志 +- `openclaw gateway status` +- `openclaw logs --follow` -### 方式二:通过命令行添加 +### 方法 2:CLI 设置 -如果您已经完成了初始安装,可以用以下命令添加飞书渠道: +如果你已经完成初始安装,可通过 CLI 添加该渠道: ```bash openclaw channels add ``` -然后根据交互式提示选择 Feishu,输入 App ID 和 App Secret 即可。 +选择 **Feishu**,然后输入 App ID 和 App Secret。 -✅ **完成配置后**,您可以使用以下命令管理网关: +✅ **配置完成后**,管理 Gateway 网关: -- `openclaw gateway status` - 查看网关运行状态 -- `openclaw gateway restart` - 重启网关以应用新配置 -- `openclaw logs --follow` - 查看实时日志 +- `openclaw gateway status` +- `openclaw gateway restart` +- `openclaw logs --follow` --- -## 第一步:创建飞书应用 +## 第 1 步:创建飞书应用 ### 1. 打开飞书开放平台 -访问 [飞书开放平台](https://open.feishu.cn/app),使用飞书账号登录。 +访问 [Feishu Open Platform](https://open.feishu.cn/app) 并登录。 -Lark(国际版)请使用 https://open.larksuite.com/app,并在配置中设置 `domain: "lark"`。 +Lark(国际版)租户应使用 [https://open.larksuite.com/app](https://open.larksuite.com/app),并在飞书配置中设置 `domain: "lark"`。 ### 2. 创建应用 -1. 点击 **创建企业自建应用** +1. 点击 **Create enterprise app** 2. 填写应用名称和描述 3. 选择应用图标 -![创建企业自建应用](/images/feishu-step2-create-app.png) +![Create enterprise app](../images/feishu-step2-create-app.png) -### 3. 获取应用凭证 +### 3. 复制凭证 -在应用的 **凭证与基础信息** 页面,复制: +在 **Credentials & Basic Info** 中,复制: -- **App ID**(格式如 `cli_xxx`) +- **App ID**(格式:`cli_xxx`) - **App Secret** -❗ **重要**:请妥善保管 App Secret,不要分享给他人。 +❗ **重要:**请将 App Secret 妥善保密。 -![获取应用凭证](/images/feishu-step3-credentials.png) +![Get credentials](../images/feishu-step3-credentials.png) -### 4. 配置应用权限 +### 4. 配置权限 -在 **权限管理** 页面,点击 **批量导入** 按钮,粘贴以下 JSON 配置一键导入所需权限: +在 **Permissions** 中,点击 **Batch import** 并粘贴: ```json { @@ -105,81 +112,71 @@ Lark(国际版)请使用 https://open.larksuite.com/app,并在配置中设 "application:application.app_message_stats.overview:readonly", "application:application:self_manage", "application:bot.menu:write", + "cardkit:card:read", "cardkit:card:write", "contact:user.employee_id:readonly", "corehr:file:download", - "docs:document.content:read", "event:ip_list", - "im:chat", "im:chat.access_event.bot_p2p_chat:read", "im:chat.members:bot_access", "im:message", "im:message.group_at_msg:readonly", - "im:message.group_msg", "im:message.p2p_msg:readonly", "im:message:readonly", "im:message:send_as_bot", - "im:resource", - "sheets:spreadsheet", - "wiki:wiki:readonly" + "im:resource" ], "user": ["aily:file:read", "aily:file:write", "im:chat.access_event.bot_p2p_chat:read"] } } ``` -![配置应用权限](/images/feishu-step4-permissions.png) +![Configure permissions](../images/feishu-step4-permissions.png) ### 5. 启用机器人能力 -在 **应用能力** > **机器人** 页面: +在 **App Capability** > **Bot** 中: -1. 开启机器人能力 -2. 配置机器人名称 +1. 启用机器人能力 +2. 设置机器人名称 -![启用机器人能力](/images/feishu-step5-bot-capability.png) +![Enable bot capability](../images/feishu-step5-bot-capability.png) ### 6. 配置事件订阅 -⚠️ **重要提醒**:在配置事件订阅前,请务必确保已完成以下步骤: +⚠️ **重要:**在设置事件订阅前,请确保: -1. 运行 `openclaw channels add` 添加了 Feishu 渠道 -2. 网关处于启动状态(可通过 `openclaw gateway status` 检查状态) +1. 你已经为飞书运行过 `openclaw channels add` +2. Gateway 网关正在运行(`openclaw gateway status`) -在 **事件订阅** 页面: +在 **Event Subscription** 中: -1. 选择 **使用长连接接收事件**(WebSocket 模式) -2. 添加事件: - - `im.message.receive_v1` - - `im.message.reaction.created_v1` - - `im.message.reaction.deleted_v1` - - `application.bot.menu_v6` +1. 选择 **Use long connection to receive events**(WebSocket) +2. 添加事件:`im.message.receive_v1` -⚠️ **注意**:如果网关未启动或渠道未添加,长连接设置将保存失败。 +⚠️ 如果 Gateway 网关未运行,长连接设置可能无法保存。 -![配置事件订阅](/images/feishu-step6-event-subscription.png) +![Configure event subscription](../images/feishu-step6-event-subscription.png) ### 7. 发布应用 -1. 在 **版本管理与发布** 页面创建版本 +1. 在 **Version Management & Release** 中创建版本 2. 提交审核并发布 -3. 等待管理员审批(企业自建应用通常自动通过) +3. 等待管理员批准(企业应用通常会自动批准) --- -## 第二步:配置 OpenClaw +## 第 2 步:配置 OpenClaw -### 通过向导配置(推荐) - -运行以下命令,根据提示粘贴 App ID 和 App Secret: +### 使用向导配置(推荐) ```bash openclaw channels add ``` -选择 **Feishu**,然后输入您在第一步获取的凭证即可。 +选择 **Feishu**,然后粘贴你的 App ID 和 App Secret。 -### 通过配置文件配置 +### 通过配置文件进行配置 编辑 `~/.openclaw/openclaw.json`: @@ -193,7 +190,7 @@ openclaw channels add main: { appId: "cli_xxx", appSecret: "xxx", - botName: "我的AI助手", + botName: "My AI assistant", }, }, }, @@ -201,18 +198,20 @@ openclaw channels add } ``` -若使用 `connectionMode: "webhook"`,需设置 `verificationToken`。飞书 Webhook 服务默认绑定 `127.0.0.1`;仅在需要不同监听地址时设置 `webhookHost`。 +如果你使用 `connectionMode: "webhook"`,请同时设置 `verificationToken` 和 `encryptKey`。飞书 webhook 服务器默认绑定到 `127.0.0.1`;只有在你明确需要不同绑定地址时,才设置 `webhookHost`。 -#### 获取 Verification Token(仅 Webhook 模式) +#### Verification Token 和 Encrypt Key(webhook 模式) -使用 Webhook 模式时,需在配置中设置 `channels.feishu.verificationToken`。获取方式: +使用 webhook 模式时,请在配置中同时设置 `channels.feishu.verificationToken` 和 `channels.feishu.encryptKey`。获取这些值的方法如下: -1. 在飞书开放平台打开您的应用 -2. 进入 **开发配置** → **事件与回调** -3. 打开 **加密策略** 选项卡 -4. 复制 **Verification Token**(校验令牌) +1. 在飞书开放平台中,打开你的应用 +2. 前往 **Development** → **Events & Callbacks**(开发配置 → 事件与回调) +3. 打开 **Encryption** 标签页(加密策略) +4. 复制 **Verification Token** 和 **Encrypt Key** -![Verification Token 位置](/images/feishu-verification-token.png) +下图展示了 **Verification Token** 的位置。**Encrypt Key** 位于同一个 **Encryption** 区域中。 + +![Verification Token location](../images/feishu-verification-token.png) ### 通过环境变量配置 @@ -223,7 +222,7 @@ export FEISHU_APP_SECRET="xxx" ### Lark(国际版)域名 -如果您的租户在 Lark(国际版),请设置域名为 `lark`(或完整域名),可配置 `channels.feishu.domain` 或 `channels.feishu.accounts..domain`: +如果你的租户位于 Lark(国际版),请将域名设置为 `lark`(或完整域名字串)。你可以在 `channels.feishu.domain` 设置,也可以按账户设置(`channels.feishu.accounts..domain`)。 ```json5 { @@ -241,14 +240,14 @@ export FEISHU_APP_SECRET="xxx" } ``` -### 配额优化 +### 配额优化标志 -可通过以下可选配置减少飞书 API 调用: +你可以使用两个可选标志来减少飞书 API 使用量: -- `typingIndicator`(默认 `true`):设为 `false` 时不发送“正在输入”状态。 -- `resolveSenderNames`(默认 `true`):设为 `false` 时不拉取发送者资料。 +- `typingIndicator`(默认 `true`):设为 `false` 时,跳过“正在输入”反应调用。 +- `resolveSenderNames`(默认 `true`):设为 `false` 时,跳过发送者资料查询调用。 -可在渠道级或账号级配置: +你可以在顶层或按账户进行设置: ```json5 { @@ -271,9 +270,9 @@ export FEISHU_APP_SECRET="xxx" --- -## 第三步:启动并测试 +## 第 3 步:启动并测试 -### 1. 启动网关 +### 1. 启动 Gateway 网关 ```bash openclaw gateway @@ -281,74 +280,74 @@ openclaw gateway ### 2. 发送测试消息 -在飞书中找到您创建的机器人,发送一条消息。 +在飞书中找到你的机器人并发送一条消息。 -### 3. 配对授权 +### 3. 批准配对 -默认情况下,机器人会回复一个 **配对码**。您需要批准此代码: +默认情况下,机器人会回复一个配对码。批准它: ```bash -openclaw pairing approve feishu <配对码> +openclaw pairing approve feishu ``` -批准后即可正常对话。 +批准后,你就可以正常聊天了。 --- -## 介绍 +## 概览 -- **飞书机器人渠道**:由网关管理的飞书机器人 -- **确定性路由**:回复始终返回飞书,模型不会选择渠道 -- **会话隔离**:私聊共享主会话;群组独立隔离 -- **WebSocket 连接**:使用飞书 SDK 的长连接模式,无需公网 URL +- **飞书机器人渠道**:由 Gateway 网关管理的飞书机器人 +- **确定性路由**:回复始终返回到飞书 +- **会话隔离**:私信共享主会话;群组彼此隔离 +- **WebSocket 连接**:通过飞书 SDK 建立长连接,无需公共 URL --- ## 访问控制 -### 私聊访问 +### 私信 -- **默认**:`dmPolicy: "pairing"`,陌生用户会收到配对码 +- **默认**:`dmPolicy: "pairing"`(未知用户会收到配对码) - **批准配对**: - ```bash - openclaw pairing list feishu # 查看待审批列表 - openclaw pairing approve feishu # 批准 - ``` -- **白名单模式**:通过 `channels.feishu.allowFrom` 配置允许的用户 Open ID -### 群组访问 + ```bash + openclaw pairing list feishu + openclaw pairing approve feishu + ``` + +- **Allowlist 模式**:设置 `channels.feishu.allowFrom`,填入允许的 Open ID + +### 群聊 **1. 群组策略**(`channels.feishu.groupPolicy`): -- `"open"` = 允许群组中所有人(默认) -- `"allowlist"` = 仅允许 `groupAllowFrom` 中的群组 -- `"disabled"` = 禁用群组消息 +- `"open"` = 允许群组中的所有人(默认) +- `"allowlist"` = 仅允许 `groupAllowFrom` +- `"disabled"` = 禁用群消息 -**2. @提及要求**(`channels.feishu.groups..requireMention`): +**2. 提及要求**(`channels.feishu.groups..requireMention`): -- `true` = 需要 @机器人才响应(默认) -- `false` = 无需 @也响应 +- `true` = 需要 @ 提及(默认) +- `false` = 无需提及也会回复 --- ## 群组配置示例 -### 允许所有群组,需要 @提及(默认行为) +### 允许所有群组,要求 @ 提及(默认) ```json5 { channels: { feishu: { groupPolicy: "open", - // 默认 requireMention: true + // Default requireMention: true }, }, } ``` -### 允许所有群组,无需 @提及 - -需要为特定群组配置: +### 允许所有群组,无需 @ 提及 ```json5 { @@ -369,16 +368,16 @@ openclaw pairing approve feishu <配对码> channels: { feishu: { groupPolicy: "allowlist", - // 群组 ID 格式为 oc_xxx + // Feishu group IDs (chat_id) look like: oc_xxx groupAllowFrom: ["oc_xxx", "oc_yyy"], }, }, } ``` -### 仅允许特定成员在群组中发信(发送者白名单) +### 限制哪些发送者可以在群组中发消息(发送者 allowlist) -除群组白名单外,该群组内**所有消息**均按发送者 open_id 校验:仅 `groups..allowFrom` 中列出的用户消息会被处理,其他成员的消息会被忽略(此为发送者级白名单,不仅针对 /reset、/new 等控制命令)。 +除了允许群组本身外,该群组中的**所有消息**还会按发送者 `open_id` 进行限制:只有列在 `groups..allowFrom` 中的用户,其消息才会被处理;其他成员发送的消息会被忽略(这是完整的发送者级限制,而不只是对 `/reset` 或 `/new` 等控制命令生效)。 ```json5 { @@ -388,7 +387,7 @@ openclaw pairing approve feishu <配对码> groupAllowFrom: ["oc_xxx"], groups: { oc_xxx: { - // 用户 open_id 格式为 ou_xxx + // Feishu user IDs (open_id) look like: ou_xxx allowFrom: ["ou_user1", "ou_user2"], }, }, @@ -401,29 +400,31 @@ openclaw pairing approve feishu <配对码> ## 获取群组/用户 ID -### 获取群组 ID(chat_id) +### 群组 ID(`chat_id`) -群组 ID 格式为 `oc_xxx`,可以通过以下方式获取: +群组 ID 看起来像 `oc_xxx`。 -**方法一**(推荐): +**方法 1(推荐)** -1. 启动网关并在群组中 @机器人发消息 -2. 运行 `openclaw logs --follow` 查看日志中的 `chat_id` +1. 启动 Gateway 网关并在群里 @ 提及机器人 +2. 运行 `openclaw logs --follow` 并查找 `chat_id` -**方法二**: -使用飞书 API 调试工具获取机器人所在群组列表。 +**方法 2** -### 获取用户 ID(open_id) +使用飞书 API 调试器列出群聊。 -用户 ID 格式为 `ou_xxx`,可以通过以下方式获取: +### 用户 ID(`open_id`) -**方法一**(推荐): +用户 ID 看起来像 `ou_xxx`。 -1. 启动网关并给机器人发消息 -2. 运行 `openclaw logs --follow` 查看日志中的 `open_id` +**方法 1(推荐)** -**方法二**: -查看配对请求列表,其中包含用户的 Open ID: +1. 启动 Gateway 网关并向机器人发送私信 +2. 运行 `openclaw logs --follow` 并查找 `open_id` + +**方法 2** + +检查配对请求中的用户 Open ID: ```bash openclaw pairing list feishu @@ -433,65 +434,61 @@ openclaw pairing list feishu ## 常用命令 -| 命令 | 说明 | +| Command | Description | | --------- | -------------- | -| `/status` | 查看机器人状态 | -| `/reset` | 重置对话会话 | -| `/model` | 查看/切换模型 | +| `/status` | 显示机器人状态 | +| `/reset` | 重置会话 | +| `/model` | 显示/切换模型 | -飞书机器人菜单建议直接在飞书开放平台的机器人能力页面配置。OpenClaw 当前支持接收 `application.bot.menu_v6` 事件,并把点击事件转换成普通文本命令(例如 `/menu `)继续走现有消息路由,但不通过渠道配置自动创建或同步菜单。 +> 注意:飞书暂不支持原生命令菜单,因此命令必须以文本形式发送。 -## 网关管理命令 +## Gateway 网关管理命令 -在配置和使用飞书渠道时,您可能需要使用以下网关管理命令: - -| 命令 | 说明 | -| -------------------------- | ----------------- | -| `openclaw gateway status` | 查看网关运行状态 | -| `openclaw gateway install` | 安装/启动网关服务 | -| `openclaw gateway stop` | 停止网关服务 | -| `openclaw gateway restart` | 重启网关服务 | -| `openclaw logs --follow` | 实时查看日志输出 | +| Command | Description | +| -------------------------- | -------------------------- | +| `openclaw gateway status` | 显示 Gateway 网关状态 | +| `openclaw gateway install` | 安装/启动 Gateway 网关服务 | +| `openclaw gateway stop` | 停止 Gateway 网关服务 | +| `openclaw gateway restart` | 重启 Gateway 网关服务 | +| `openclaw logs --follow` | 跟踪 Gateway 网关日志 | --- ## 故障排除 -### 机器人在群组中不响应 +### 机器人在群聊中没有响应 -1. 检查机器人是否已添加到群组 -2. 检查是否 @了机器人(默认需要 @提及) -3. 检查 `groupPolicy` 是否为 `"disabled"` -4. 查看日志:`openclaw logs --follow` +1. 确保机器人已加入群组 +2. 确保你 @ 提及了机器人(默认行为) +3. 检查 `groupPolicy` 未设置为 `"disabled"` +4. 检查日志:`openclaw logs --follow` -### 机器人收不到消息 +### 机器人未接收到消息 -1. 检查应用是否已发布并审批通过 -2. 检查事件订阅是否配置正确(`im.message.receive_v1`) -3. 检查是否选择了 **长连接** 模式 -4. 检查应用权限是否完整 -5. 检查网关是否正在运行:`openclaw gateway status` -6. 查看实时日志:`openclaw logs --follow` +1. 确保应用已发布并获批准 +2. 确保事件订阅包含 `im.message.receive_v1` +3. 确保已启用**长连接** +4. 确保应用权限完整 +5. 确保 Gateway 网关正在运行:`openclaw gateway status` +6. 检查日志:`openclaw logs --follow` -### App Secret 泄露怎么办 +### App Secret 泄露 -1. 在飞书开放平台重置 App Secret -2. 更新配置文件中的 App Secret -3. 重启网关 +1. 在飞书开放平台中重置 App Secret +2. 在你的配置中更新 App Secret +3. 重启 Gateway 网关 -### 发送消息失败 +### 消息发送失败 -1. 检查应用是否有 `im:message:send_as_bot` 权限 -2. 检查应用是否已发布 -3. 查看日志获取详细错误信息 +1. 确保应用具有 `im:message:send_as_bot` 权限 +2. 确保应用已发布 +3. 查看日志以获取详细错误信息 --- ## 高级配置 -### 多账号配置 - -如果需要管理多个飞书机器人,可配置 `defaultAccount` 指定出站未显式指定 `accountId` 时使用的账号: +### 多账户 ```json5 { @@ -502,13 +499,13 @@ openclaw pairing list feishu main: { appId: "cli_xxx", appSecret: "xxx", - botName: "主机器人", + botName: "Primary bot", }, backup: { appId: "cli_yyy", appSecret: "yyy", - botName: "备用机器人", - enabled: false, // 暂时禁用 + botName: "Backup bot", + enabled: false, }, }, }, @@ -516,104 +513,102 @@ openclaw pairing list feishu } ``` +`defaultAccount` 用于控制当出站 API 未显式指定 `accountId` 时,使用哪个飞书账户。 + ### 消息限制 -- `textChunkLimit`:出站文本分块大小(默认 2000 字符) -- `mediaMaxMb`:媒体上传/下载限制(默认 30MB) +- `textChunkLimit`:出站文本分块大小(默认:2000 个字符) +- `mediaMaxMb`:媒体上传/下载限制(默认:30 MB) -### 流式输出 +### 流式传输 -飞书支持通过交互式卡片实现流式输出,机器人会实时更新卡片内容显示生成进度。默认配置: +飞书通过交互式卡片支持流式回复。启用后,机器人会在生成文本时更新卡片。 ```json5 { channels: { feishu: { streaming: true, // 启用流式卡片输出(默认 true) - blockStreamingCoalesce: { - enabled: true, - minDelayMs: 50, - maxDelayMs: 250, - }, + blockStreaming: true, // 启用分块流式传输(默认 true) }, }, } ``` -如需禁用流式输出(等待完整回复后一次性发送),可设置 `streaming: false`。 +将 `streaming: false` 设为等待完整回复生成后再发送。 -### 交互式卡片 +### ACP 会话 -OpenClaw 默认会在需要时发送 Markdown 卡片;如果你需要完整的 Feishu 原生交互式卡片,也可以显式发送原始 `card` payload。 +飞书支持以下 ACP 场景: -- 默认路径:文本自动渲染或 Markdown 卡片 -- 显式卡片:通过消息动作的 `card` 参数发送原始交互卡片 -- 更新卡片:同一消息支持后续 patch/update +- 私信 +- 群组话题会话 -卡片按钮回调当前走文本回退路径: +飞书 ACP 由文本命令驱动。没有原生斜杠命令菜单,因此请直接在会话中使用 `/acp ...` 消息。 -- 若 `action.value.text` 存在,则作为入站文本继续处理 -- 若 `action.value.command` 存在,则作为命令文本继续处理 -- 其他对象值会序列化为 JSON 文本 +#### 持久化 ACP 绑定 -这样可以保持与现有消息/命令路由兼容,而不要求下游先理解 Feishu 专有的交互 payload。 - -### 表情反应 - -飞书渠道现已完整支持表情反应生命周期: - -- 接收 `reaction created` -- 接收 `reaction deleted` -- 主动添加反应 -- 主动删除自身反应 -- 查询消息上的反应列表 - -是否把入站反应转成内部消息,可通过 `reactionNotifications` 控制: - -| 值 | 行为 | -| ----- | ---------------------------- | -| `off` | 不生成反应通知 | -| `own` | 仅当反应发生在机器人消息上时 | -| `all` | 所有可验证的反应都生成通知 | - -### 消息引用 - -在群聊中,机器人的回复可以引用用户发送的原始消息,让对话上下文更加清晰。 - -配置选项: +使用顶层类型化 ACP 绑定,将飞书私信或话题会话固定到持久化 ACP 会话。 ```json5 { - channels: { - feishu: { - // 账户级别配置(默认 "all") - replyToMode: "all", - groups: { - oc_xxx: { - // 特定群组可以覆盖 - replyToMode: "first", + agents: { + list: [ + { + id: "codex", + runtime: { + type: "acp", + acp: { + agent: "codex", + backend: "acpx", + mode: "persistent", + cwd: "/workspace/openclaw", + }, }, }, - }, + ], }, + bindings: [ + { + type: "acp", + agentId: "codex", + match: { + channel: "feishu", + accountId: "default", + peer: { kind: "direct", id: "ou_1234567890" }, + }, + }, + { + type: "acp", + agentId: "codex", + match: { + channel: "feishu", + accountId: "default", + peer: { kind: "group", id: "oc_group_chat:topic:om_topic_root" }, + }, + acp: { label: "codex-feishu-topic" }, + }, + ], } ``` -`replyToMode` 值说明: +#### 从聊天中按线程绑定 ACP 生成 -| 值 | 行为 | -| --------- | ---------------------------------- | -| `"off"` | 不引用原消息(私聊默认值) | -| `"first"` | 仅在第一条回复时引用原消息 | -| `"all"` | 所有回复都引用原消息(群聊默认值) | +在飞书私信或话题会话中,你可以就地生成并绑定一个 ACP 会话: -> 注意:消息引用功能与流式卡片输出(`streaming: true`)不能同时使用。当启用流式输出时,回复会以卡片形式呈现,不会显示引用。 +```text +/acp spawn codex --thread here +``` -### 多 Agent 路由 +说明: -通过 `bindings` 配置,您可以用一个飞书机器人对接多个不同功能或性格的 Agent。系统会根据用户 ID 或群组 ID 自动将对话分发到对应的 Agent。 +- `--thread here` 适用于私信和飞书话题。 +- 绑定后的私信/话题中的后续消息会直接路由到该 ACP 会话。 +- v1 不支持针对通用的非话题群聊。 -配置示例: +### 多智能体路由 + +使用 `bindings` 将飞书私信或群组路由到不同的智能体。 ```json5 { @@ -634,91 +629,81 @@ OpenClaw 默认会在需要时发送 Markdown 卡片;如果你需要完整的 }, bindings: [ { - // 用户 A 的私聊 → main agent agentId: "main", match: { channel: "feishu", - peer: { kind: "dm", id: "ou_28b31a88..." }, + peer: { kind: "direct", id: "ou_xxx" }, }, }, { - // 用户 B 的私聊 → clawd-fan agent agentId: "clawd-fan", match: { channel: "feishu", - peer: { kind: "dm", id: "ou_0fe6b1c9..." }, + peer: { kind: "direct", id: "ou_yyy" }, }, }, { - // 某个群组 → clawd-xi agent agentId: "clawd-xi", match: { channel: "feishu", - peer: { kind: "group", id: "oc_xxx..." }, + peer: { kind: "group", id: "oc_zzz" }, }, }, ], } ``` -匹配规则说明: +路由字段: -| 字段 | 说明 | -| ----------------- | --------------------------------------------- | -| `agentId` | 目标 Agent 的 ID,需要在 `agents.list` 中定义 | -| `match.channel` | 渠道类型,这里固定为 `"feishu"` | -| `match.peer.kind` | 对话类型:`"dm"`(私聊)或 `"group"`(群组) | -| `match.peer.id` | 用户 Open ID(`ou_xxx`)或群组 ID(`oc_xxx`) | +- `match.channel`:`"feishu"` +- `match.peer.kind`:`"direct"` 或 `"group"` +- `match.peer.id`:用户 Open ID(`ou_xxx`)或群组 ID(`oc_xxx`) -> 获取 ID 的方法:参见上文 [获取群组/用户 ID](#获取群组用户-id) 章节。 +查找提示请参见 [获取群组/用户 ID](#get-groupuser-ids)。 --- ## 配置参考 -完整配置请参考:[网关配置](/gateway/configuration) +完整配置:[Gateway 网关配置](/gateway/configuration) -主要选项: +关键选项: -| 配置项 | 说明 | 默认值 | -| ------------------------------------------------- | --------------------------------- | ---------------- | -| `channels.feishu.enabled` | 启用/禁用渠道 | `true` | -| `channels.feishu.domain` | API 域名(`feishu` 或 `lark`) | `feishu` | -| `channels.feishu.connectionMode` | 事件传输模式(websocket/webhook) | `websocket` | -| `channels.feishu.defaultAccount` | 出站路由默认账号 ID | `default` | -| `channels.feishu.verificationToken` | Webhook 模式必填 | - | -| `channels.feishu.webhookPath` | Webhook 路由路径 | `/feishu/events` | -| `channels.feishu.webhookHost` | Webhook 监听地址 | `127.0.0.1` | -| `channels.feishu.webhookPort` | Webhook 监听端口 | `3000` | -| `channels.feishu.accounts..appId` | 应用 App ID | - | -| `channels.feishu.accounts..appSecret` | 应用 App Secret | - | -| `channels.feishu.accounts..domain` | 单账号 API 域名覆盖 | `feishu` | -| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` | -| `channels.feishu.allowFrom` | 私聊白名单(open_id 列表) | - | -| `channels.feishu.groupPolicy` | 群组策略 | `allowlist` | -| `channels.feishu.groupAllowFrom` | 群组白名单 | - | -| `channels.feishu.groups..requireMention` | 是否需要 @提及 | `true` | -| `channels.feishu.groups..enabled` | 是否启用该群组 | `true` | -| `channels.feishu.replyInThread` | 群聊回复是否进入飞书话题线程 | `disabled` | -| `channels.feishu.groupSessionScope` | 群聊会话隔离粒度 | `group` | -| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` | -| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` | -| `channels.feishu.streaming` | 启用流式卡片输出 | `true` | -| `channels.feishu.blockStreamingCoalesce.enabled` | 启用块级流式合并 | `true` | -| `channels.feishu.typingIndicator` | 发送“正在输入”状态 | `true` | -| `channels.feishu.resolveSenderNames` | 拉取发送者名称 | `true` | -| `channels.feishu.reactionNotifications` | 入站反应通知策略 | `own` | +| Setting | Description | Default | +| ------------------------------------------------- | -------------------------------- | ---------------- | +| `channels.feishu.enabled` | 启用/禁用渠道 | `true` | +| `channels.feishu.domain` | API 域名(`feishu` 或 `lark`) | `feishu` | +| `channels.feishu.connectionMode` | 事件传输模式 | `websocket` | +| `channels.feishu.defaultAccount` | 出站路由的默认账户 ID | `default` | +| `channels.feishu.verificationToken` | webhook 模式必填 | - | +| `channels.feishu.encryptKey` | webhook 模式必填 | - | +| `channels.feishu.webhookPath` | webhook 路由路径 | `/feishu/events` | +| `channels.feishu.webhookHost` | webhook 绑定主机 | `127.0.0.1` | +| `channels.feishu.webhookPort` | webhook 绑定端口 | `3000` | +| `channels.feishu.accounts..appId` | App ID | - | +| `channels.feishu.accounts..appSecret` | App Secret | - | +| `channels.feishu.accounts..domain` | 按账户覆盖 API 域名 | `feishu` | +| `channels.feishu.dmPolicy` | 私信策略 | `pairing` | +| `channels.feishu.allowFrom` | 私信 allowlist(`open_id` 列表) | - | +| `channels.feishu.groupPolicy` | 群组策略 | `open` | +| `channels.feishu.groupAllowFrom` | 群组 allowlist | - | +| `channels.feishu.groups..requireMention` | 要求 @ 提及 | `true` | +| `channels.feishu.groups..enabled` | 启用群组 | `true` | +| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` | +| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` | +| `channels.feishu.streaming` | 启用流式卡片输出 | `true` | +| `channels.feishu.blockStreaming` | 启用分块流式传输 | `true` | --- -## dmPolicy 策略说明 +## dmPolicy 参考 -| 值 | 行为 | -| ------------- | -------------------------------------------------- | -| `"pairing"` | **默认**。未知用户收到配对码,管理员批准后才能对话 | -| `"allowlist"` | 仅 `allowFrom` 列表中的用户可对话,其他静默忽略 | -| `"open"` | 允许所有人对话(需在 allowFrom 中加 `"*"`) | -| `"disabled"` | 完全禁止私聊 | +| Value | Behavior | +| ------------- | ---------------------------------------------------- | +| `"pairing"` | **默认。**未知用户会收到配对码;必须获批准后才能使用 | +| `"allowlist"` | 只有 `allowFrom` 中的用户可以聊天 | +| `"open"` | 允许所有用户(要求 `allowFrom` 中有 `"*"`) | +| `"disabled"` | 禁用私信 | --- @@ -726,17 +711,17 @@ OpenClaw 默认会在需要时发送 Markdown 卡片;如果你需要完整的 ### 接收 -- ✅ 文本消息 -- ✅ 富文本(帖子) +- ✅ 文本 +- ✅ 富文本(post) - ✅ 图片 - ✅ 文件 - ✅ 音频 - ✅ 视频 -- ✅ 表情包 +- ✅ 贴纸 ### 发送 -- ✅ 文本消息 +- ✅ 文本 - ✅ 图片 - ✅ 文件 - ✅ 音频 diff --git a/docs/zh-CN/channels/nostr.md b/docs/zh-CN/channels/nostr.md index 7d0359ce21d..47efa40aedf 100644 --- a/docs/zh-CN/channels/nostr.md +++ b/docs/zh-CN/channels/nostr.md @@ -1,14 +1,14 @@ --- read_when: - - 你希望 OpenClaw 通过 Nostr 接收私信 - - 你正在设置去中心化消息 -summary: 通过 NIP-04 加密消息的 Nostr 私信渠道 + - 你想让 OpenClaw 通过 Nostr 接收私信 + - 你正在设置去中心化消息传递 +summary: 通过 NIP-04 加密消息实现的 Nostr 私信渠道 title: Nostr x-i18n: - generated_at: "2026-02-03T07:44:13Z" - model: claude-opus-4-5 - provider: pi - source_hash: 6b9fe4c74bf5e7c0f59bbaa129ec5270fd29a248551a8a9a7dde6cff8fb46111 + generated_at: "2026-03-16T06:20:37Z" + model: gpt-5.4 + provider: openai + source_hash: fcce57da49256971420c4bb099aebb7944f8c7e8619b17b163da685add225001 source_path: channels/nostr.md workflow: 15 --- @@ -17,21 +17,21 @@ x-i18n: **状态:** 可选插件(默认禁用)。 -Nostr 是一个去中心化的社交网络协议。此渠道使 OpenClaw 能够通过 NIP-04 接收和回复加密私信(DMs)。 +Nostr 是一种用于社交网络的去中心化协议。此渠道使 OpenClaw 能够通过 NIP-04 接收并回复加密私信。 ## 安装(按需) ### 新手引导(推荐) -- 新手引导向导(`openclaw onboard`)和 `openclaw channels add` 会列出可选的渠道插件。 -- 选择 Nostr 会提示你按需安装插件。 +- 设置向导(`openclaw onboard`)和 `openclaw channels add` 会列出可选渠道插件。 +- 选择 Nostr 时,系统会提示你按需安装该插件。 -安装默认值: +安装默认行为: -- **Dev 渠道 + git checkout 可用:** 使用本地插件路径。 -- **Stable/Beta:** 从 npm 下载。 +- **Dev 渠道 + 可用的 git 检出:** 使用本地插件路径。 +- **稳定版 / Beta:** 从 npm 下载。 -你可以随时在提示中覆盖选择。 +你始终可以在提示中覆盖该选择。 ### 手动安装 @@ -39,24 +39,33 @@ Nostr 是一个去中心化的社交网络协议。此渠道使 OpenClaw 能够 openclaw plugins install @openclaw/nostr ``` -使用本地 checkout(开发工作流): +使用本地检出(dev 工作流): ```bash openclaw plugins install --link /extensions/nostr ``` -安装或启用插件后重启 Gateway 网关。 +安装或启用插件后,重启 Gateway 网关。 + +### 非交互式设置 + +```bash +openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" +openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" --relay-urls "wss://relay.damus.io,wss://relay.primal.net" +``` + +使用 `--use-env` 可将 `NOSTR_PRIVATE_KEY` 保留在环境中,而不是将密钥存储在配置里。 ## 快速设置 -1. 生成 Nostr 密钥对(如需要): +1. 生成一个 Nostr 密钥对(如有需要): ```bash -# 使用 nak +# Using nak nak key generate ``` -2. 添加到配置: +2. 添加到配置中: ```json { @@ -78,19 +87,19 @@ export NOSTR_PRIVATE_KEY="nsec1..." ## 配置参考 -| 键 | 类型 | 默认值 | 描述 | +| 键 | 类型 | 默认值 | 说明 | | ------------ | -------- | ------------------------------------------- | --------------------------- | | `privateKey` | string | 必填 | `nsec` 或十六进制格式的私钥 | | `relays` | string[] | `['wss://relay.damus.io', 'wss://nos.lol']` | 中继 URL(WebSocket) | | `dmPolicy` | string | `pairing` | 私信访问策略 | -| `allowFrom` | string[] | `[]` | 允许的发送者公钥 | -| `enabled` | boolean | `true` | 启用/禁用渠道 | +| `allowFrom` | string[] | `[]` | 允许的发送方公钥 | +| `enabled` | boolean | `true` | 启用 / 禁用渠道 | | `name` | string | - | 显示名称 | -| `profile` | object | - | NIP-01 个人资料元数据 | +| `profile` | object | - | NIP-01 资料元数据 | -## 个人资料元数据 +## 资料元数据 -个人资料数据作为 NIP-01 `kind:0` 事件发布。你可以从控制界面(Channels -> Nostr -> Profile)管理它,或直接在配置中设置。 +资料数据会作为 NIP-01 `kind:0` 事件发布。你可以在控制 UI 中管理它(Channels -> Nostr -> Profile),也可以直接在配置中设置。 示例: @@ -114,19 +123,19 @@ export NOSTR_PRIVATE_KEY="nsec1..." } ``` -注意事项: +注意: -- 个人资料 URL 必须使用 `https://`。 -- 从中继导入会合并字段并保留本地覆盖。 +- 资料 URL 必须使用 `https://`。 +- 从中继导入时会合并字段,并保留本地覆盖项。 ## 访问控制 ### 私信策略 -- **pairing**(默认):未知发送者会收到配对码。 +- **pairing**(默认):未知发送方会收到一个配对码。 - **allowlist**:只有 `allowFrom` 中的公钥可以发送私信。 -- **open**:公开接收私信(需要 `allowFrom: ["*"]`)。 -- **disabled**:忽略接收的私信。 +- **open**:公开接收入站私信(要求 `allowFrom: ["*"]`)。 +- **disabled**:忽略入站私信。 ### 允许列表示例 @@ -166,26 +175,26 @@ export NOSTR_PRIVATE_KEY="nsec1..." 提示: -- 使用 2-3 个中继以实现冗余。 +- 使用 2 到 3 个中继以实现冗余。 - 避免使用过多中继(延迟、重复)。 - 付费中继可以提高可靠性。 -- 本地中继适合测试(`ws://localhost:7777`)。 +- 本地中继也适合测试(`ws://localhost:7777`)。 ## 协议支持 -| NIP | 状态 | 描述 | -| ------ | ------ | ----------------------------- | -| NIP-01 | 已支持 | 基本事件格式 + 个人资料元数据 | -| NIP-04 | 已支持 | 加密私信(`kind:4`) | -| NIP-17 | 计划中 | 礼物包装私信 | -| NIP-44 | 计划中 | 版本化加密 | +| NIP | 状态 | 说明 | +| ------ | ------ | ------------------------- | +| NIP-01 | 已支持 | 基础事件格式 + 资料元数据 | +| NIP-04 | 已支持 | 加密私信(`kind:4`) | +| NIP-17 | 计划中 | Gift-wrapped 私信 | +| NIP-44 | 计划中 | 版本化加密 | ## 测试 ### 本地中继 ```bash -# 启动 strfry +# Start strfry docker run -p 7777:7777 ghcr.io/hoytech/strfry ``` @@ -203,38 +212,38 @@ docker run -p 7777:7777 ghcr.io/hoytech/strfry ### 手动测试 1. 从日志中记下机器人公钥(npub)。 -2. 打开 Nostr 客户端(Damus、Amethyst 等)。 -3. 向机器人公钥发送私信。 -4. 验证响应。 +2. 打开一个 Nostr 客户端(Damus、Amethyst 等)。 +3. 向该机器人公钥发送私信。 +4. 验证回复。 ## 故障排除 -### 未收到消息 +### 未接收到消息 -- 验证私钥是否有效。 -- 确保中继 URL 可访问并使用 `wss://`(本地使用 `ws://`)。 +- 验证私钥有效。 +- 确保中继 URL 可访问,并使用 `wss://`(本地则使用 `ws://`)。 - 确认 `enabled` 不是 `false`。 - 检查 Gateway 网关日志中的中继连接错误。 -### 未发送响应 +### 未发送回复 - 检查中继是否接受写入。 -- 验证出站连接。 +- 验证出站连接性。 - 注意中继速率限制。 -### 重复响应 +### 重复回复 -- 使用多个中继时属于正常现象。 -- 消息按事件 ID 去重;只有首次投递会触发响应。 +- 使用多个中继时这是预期行为。 +- 消息会按事件 ID 去重;只有首次投递会触发回复。 -## 安全 +## 安全性 - 切勿提交私钥。 -- 使用环境变量存储密钥。 -- 生产环境机器人考虑使用 `allowlist`。 +- 对密钥使用环境变量。 +- 对生产机器人考虑使用 `allowlist`。 ## 限制(MVP) - 仅支持私信(不支持群聊)。 - 不支持媒体附件。 -- 仅支持 NIP-04(计划支持 NIP-17 礼物包装)。 +- 仅支持 NIP-04(计划支持 NIP-17 gift-wrap)。 diff --git a/docs/zh-CN/channels/synology-chat.md b/docs/zh-CN/channels/synology-chat.md new file mode 100644 index 00000000000..4323e5d12d7 --- /dev/null +++ b/docs/zh-CN/channels/synology-chat.md @@ -0,0 +1,138 @@ +--- +read_when: + - 设置 OpenClaw 与 Synology Chat + - 调试 Synology Chat webhook 路由 +summary: Synology Chat webhook 设置与 OpenClaw 配置 +title: Synology Chat +x-i18n: + generated_at: "2026-03-16T06:20:51Z" + model: gpt-5.4 + provider: openai + source_hash: 7d77598ea759f89873a1edf0a3a7e7fedc1e4a7067709aaca6b999056a89eb1a + source_path: channels/synology-chat.md + workflow: 15 +--- + +# Synology Chat(插件) + +状态:通过插件支持,作为使用 Synology Chat webhook 的私信渠道。 +该插件接受来自 Synology Chat 出站 webhook 的入站消息,并通过 Synology Chat 入站 webhook 发送回复。 + +## 需要插件 + +Synology Chat 基于插件,不属于默认的核心渠道安装内容。 + +从本地检出安装: + +```bash +openclaw plugins install ./extensions/synology-chat +``` + +详情:[插件](/tools/plugin) + +## 快速设置 + +1. 安装并启用 Synology Chat 插件。 + - `openclaw onboard` 现在会在与 `openclaw channels add` 相同的渠道设置列表中显示 Synology Chat。 + - 非交互式设置:`openclaw channels add --channel synology-chat --token --url ` +2. 在 Synology Chat 集成中: + - 创建一个入站 webhook 并复制其 URL。 + - 使用你的 secret token 创建一个出站 webhook。 +3. 将出站 webhook URL 指向你的 OpenClaw Gateway 网关: + - 默认是 `https://gateway-host/webhook/synology`。 + - 或者使用你自定义的 `channels.synology-chat.webhookPath`。 +4. 在 OpenClaw 中完成设置。 + - 引导式:`openclaw onboard` + - 直接设置:`openclaw channels add --channel synology-chat --token --url ` +5. 重启 Gateway 网关,并向 Synology Chat 机器人发送一条私信。 + +最小配置: + +```json5 +{ + channels: { + "synology-chat": { + enabled: true, + token: "synology-outgoing-token", + incomingUrl: "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=...", + webhookPath: "/webhook/synology", + dmPolicy: "allowlist", + allowedUserIds: ["123456"], + rateLimitPerMinute: 30, + allowInsecureSsl: false, + }, + }, +} +``` + +## 环境变量 + +对于默认账户,你可以使用环境变量: + +- `SYNOLOGY_CHAT_TOKEN` +- `SYNOLOGY_CHAT_INCOMING_URL` +- `SYNOLOGY_NAS_HOST` +- `SYNOLOGY_ALLOWED_USER_IDS`(逗号分隔) +- `SYNOLOGY_RATE_LIMIT` +- `OPENCLAW_BOT_NAME` + +配置值会覆盖环境变量。 + +## 私信策略与访问控制 + +- 推荐的默认值是 `dmPolicy: "allowlist"`。 +- `allowedUserIds` 接受 Synology 用户 ID 列表(或逗号分隔字符串)。 +- 在 `allowlist` 模式下,空的 `allowedUserIds` 列表会被视为配置错误,webhook 路由将不会启动(如需允许所有人,请使用 `dmPolicy: "open"`)。 +- `dmPolicy: "open"` 允许任何发送方。 +- `dmPolicy: "disabled"` 会阻止私信。 +- 配对批准可配合以下命令使用: + - `openclaw pairing list synology-chat` + - `openclaw pairing approve synology-chat ` + +## 出站投递 + +使用数字形式的 Synology Chat 用户 ID 作为目标。 + +示例: + +```bash +openclaw message send --channel synology-chat --target 123456 --text "Hello from OpenClaw" +openclaw message send --channel synology-chat --target synology-chat:123456 --text "Hello again" +``` + +支持通过基于 URL 的文件投递发送媒体。 + +## 多账户 + +支持在 `channels.synology-chat.accounts` 下配置多个 Synology Chat 账户。 +每个账户都可以覆盖 token、入站 URL、webhook 路径、私信策略和限制。 + +```json5 +{ + channels: { + "synology-chat": { + enabled: true, + accounts: { + default: { + token: "token-a", + incomingUrl: "https://nas-a.example.com/...token=...", + }, + alerts: { + token: "token-b", + incomingUrl: "https://nas-b.example.com/...token=...", + webhookPath: "/webhook/synology-alerts", + dmPolicy: "allowlist", + allowedUserIds: ["987654"], + }, + }, + }, + }, +} +``` + +## 安全说明 + +- 妥善保管 `token`,如果泄露请轮换。 +- 除非你明确可信任本地 NAS 的自签名证书,否则请保持 `allowInsecureSsl: false`。 +- 入站 webhook 请求会按 token 验证,并按发送方进行速率限制。 +- 生产环境优先使用 `dmPolicy: "allowlist"`。 diff --git a/docs/zh-CN/cli/index.md b/docs/zh-CN/cli/index.md index e7ae99ef935..46be3ef8ab1 100644 --- a/docs/zh-CN/cli/index.md +++ b/docs/zh-CN/cli/index.md @@ -1,14 +1,14 @@ --- read_when: - - 添加或修改 CLI 命令或选项 - - 为新命令界面编写文档 -summary: OpenClaw `openclaw` 命令、子命令和选项的 CLI 参考 + - 添加或修改 CLI 命令或选项时 + - 为新的命令界面编写文档时 +summary: "`openclaw` 命令、子命令和选项的 OpenClaw CLI 参考" title: CLI 参考 x-i18n: - generated_at: "2026-02-03T07:47:54Z" - model: claude-opus-4-5 - provider: pi - source_hash: a73923763d7b89d4b183f569d543927ffbfd1f3e02f9e66639913f6daf226850 + generated_at: "2026-03-16T06:22:35Z" + model: gpt-5.4 + provider: openai + source_hash: a2bca34fca64558a8d91fc640ad3880e79677e81d0f605083edc6cbe86bfba53 source_path: cli/index.md workflow: 15 --- @@ -23,8 +23,10 @@ x-i18n: - [`onboard`](/cli/onboard) - [`configure`](/cli/configure) - [`config`](/cli/config) +- [`completion`](/cli/completion) - [`doctor`](/cli/doctor) - [`dashboard`](/cli/dashboard) +- [`backup`](/cli/backup) - [`reset`](/cli/reset) - [`uninstall`](/cli/uninstall) - [`update`](/cli/update) @@ -40,6 +42,7 @@ x-i18n: - [`system`](/cli/system) - [`models`](/cli/models) - [`memory`](/cli/memory) +- [`directory`](/cli/directory) - [`nodes`](/cli/nodes) - [`devices`](/cli/devices) - [`node`](/cli/node) @@ -53,42 +56,46 @@ x-i18n: - [`hooks`](/cli/hooks) - [`webhooks`](/cli/webhooks) - [`pairing`](/cli/pairing) +- [`qr`](/cli/qr) - [`plugins`](/cli/plugins)(插件命令) - [`channels`](/cli/channels) - [`security`](/cli/security) +- [`secrets`](/cli/secrets) - [`skills`](/cli/skills) +- [`daemon`](/cli/daemon)(Gateway 网关服务命令的旧别名) +- [`clawbot`](/cli/clawbot)(旧别名命名空间) - [`voicecall`](/cli/voicecall)(插件;如已安装) ## 全局标志 -- `--dev`:将状态隔离到 `~/.openclaw-dev` 下并调整默认端口。 +- `--dev`:将状态隔离到 `~/.openclaw-dev` 下,并变更默认端口。 - `--profile `:将状态隔离到 `~/.openclaw-` 下。 - `--no-color`:禁用 ANSI 颜色。 -- `--update`:`openclaw update` 的简写(仅限源码安装)。 -- `-V`、`--version`、`-v`:打印版本并退出。 +- `--update`:`openclaw update` 的简写(仅适用于源码安装)。 +- `-V`, `--version`, `-v`:打印版本并退出。 ## 输出样式 - ANSI 颜色和进度指示器仅在 TTY 会话中渲染。 -- OSC-8 超链接在支持的终端中渲染为可点击链接;否则回退到纯 URL。 -- `--json`(以及支持的地方使用 `--plain`)禁用样式以获得干净输出。 -- `--no-color` 禁用 ANSI 样式;也支持 `NO_COLOR=1`。 -- 长时间运行的命令显示进度指示器(支持时使用 OSC 9;4)。 +- OSC-8 超链接会在受支持的终端中显示为可点击链接;否则会回退为纯 URL。 +- `--json`(以及在支持处的 `--plain`)会禁用样式,以获得干净输出。 +- `--no-color` 会禁用 ANSI 样式;同时也支持 `NO_COLOR=1`。 +- 长时间运行的命令会显示进度指示器(支持时使用 OSC 9;4)。 -## 颜色调色板 +## 调色板 -OpenClaw 在 CLI 输出中使用龙虾调色板。 +OpenClaw 在 CLI 输出中使用龙虾色调调色板。 -- `accent`(#FF5A2D):标题、标签、主要高亮。 -- `accentBright`(#FF7A3D):命令名称、强调。 -- `accentDim`(#D14A22):次要高亮文本。 -- `info`(#FF8A5B):信息性值。 -- `success`(#2FBF71):成功状态。 -- `warn`(#FFB020):警告、回退、注意。 -- `error`(#E23D2D):错误、失败。 -- `muted`(#8B7F77):弱化、元数据。 +- `accent` (#FF5A2D):标题、标签、主要高亮。 +- `accentBright` (#FF7A3D):命令名称、强调。 +- `accentDim` (#D14A22):次级高亮文本。 +- `info` (#FF8A5B):信息性值。 +- `success` (#2FBF71):成功状态。 +- `warn` (#FFB020):警告、回退、注意事项。 +- `error` (#E23D2D):错误、失败。 +- `muted` (#8B7F77):弱化显示、元数据。 -调色板权威来源:`src/terminal/palette.ts`(又名"lobster seam")。 +调色板唯一来源:`src/terminal/palette.ts`(也称为 “lobster seam”)。 ## 命令树 @@ -101,9 +108,17 @@ openclaw [--dev] [--profile ] get set unset + completion doctor + dashboard + backup + create + verify security audit + secrets + reload + migrate reset uninstall update @@ -115,6 +130,7 @@ openclaw [--dev] [--profile ] remove login logout + directory skills list info @@ -152,6 +168,13 @@ openclaw [--dev] [--profile ] stop restart run + daemon + status + install + uninstall + start + stop + restart logs system event @@ -238,49 +261,59 @@ openclaw [--dev] [--profile ] pairing list approve + qr + clawbot + qr docs dns setup tui ``` -注意:插件可以添加额外的顶级命令(例如 `openclaw voicecall`)。 +注意:插件可以添加额外的顶层命令(例如 `openclaw voicecall`)。 ## 安全 -- `openclaw security audit` — 审计配置 + 本地状态中常见的安全隐患。 +- `openclaw security audit` — 审计配置 + 本地状态中常见的安全陷阱。 - `openclaw security audit --deep` — 尽力进行实时 Gateway 网关探测。 -- `openclaw security audit --fix` — 收紧安全默认值并 chmod 状态/配置。 +- `openclaw security audit --fix` — 收紧安全默认值并对状态 / 配置执行 chmod。 + +## 密钥 + +- `openclaw secrets reload` — 重新解析引用,并以原子方式替换运行时快照。 +- `openclaw secrets audit` — 扫描明文残留、未解析引用和优先级漂移。 +- `openclaw secrets configure` — 用于提供商设置 + SecretRef 映射 + 预检 / 应用的交互式助手。 +- `openclaw secrets apply --from ` — 应用先前生成的计划(支持 `--dry-run`)。 ## 插件 管理扩展及其配置: -- `openclaw plugins list` — 发现插件(使用 `--json` 获取机器可读输出)。 +- `openclaw plugins list` — 发现插件(机器输出请使用 `--json`)。 - `openclaw plugins info ` — 显示插件详情。 - `openclaw plugins install ` — 安装插件(或将插件路径添加到 `plugins.load.paths`)。 - `openclaw plugins enable ` / `disable ` — 切换 `plugins.entries..enabled`。 - `openclaw plugins doctor` — 报告插件加载错误。 -大多数插件更改需要重启 Gateway 网关。参见 [/plugin](/tools/plugin)。 +大多数插件更改都需要重启 gateway。参见 [/plugin](/tools/plugin)。 -## 记忆 +## 内存 -对 `MEMORY.md` + `memory/*.md` 进行向量搜索: +对 `MEMORY.md` + `memory/*.md` 执行向量搜索: -- `openclaw memory status` — 显示索引统计。 -- `openclaw memory index` — 重新索引记忆文件。 -- `openclaw memory search ""` — 对记忆进行语义搜索。 +- `openclaw memory status` — 显示索引统计信息。 +- `openclaw memory index` — 重新索引内存文件。 +- `openclaw memory search ""`(或 `--query ""`)— 对内存执行语义搜索。 ## 聊天斜杠命令 聊天消息支持 `/...` 命令(文本和原生)。参见 [/tools/slash-commands](/tools/slash-commands)。 -亮点: +重点: - `/status` 用于快速诊断。 - `/config` 用于持久化配置更改。 -- `/debug` 用于仅运行时的配置覆盖(内存中,不写入磁盘;需要 `commands.debug: true`)。 +- `/debug` 用于仅运行时的配置覆盖(内存中,不写磁盘;要求 `commands.debug: true`)。 ## 设置 + 新手引导 @@ -291,32 +324,35 @@ openclaw [--dev] [--profile ] 选项: - `--workspace `:智能体工作区路径(默认 `~/.openclaw/workspace`)。 -- `--wizard`:运行新手引导向导。 +- `--wizard`:运行设置向导。 - `--non-interactive`:无提示运行向导。 - `--mode `:向导模式。 - `--remote-url `:远程 Gateway 网关 URL。 -- `--remote-token `:远程 Gateway 网关令牌。 +- `--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 ` -- `--flow `(manual 是 advanced 的别名) -- `--auth-choice ` -- `--token-provider `(非交互式;与 `--auth-choice token` 配合使用) -- `--token `(非交互式;与 `--auth-choice token` 配合使用) +- `--flow `(`manual` 是 `advanced` 的别名) +- `--auth-choice ` +- `--token-provider `(非交互式;与 `--auth-choice token` 一起使用) +- `--token `(非交互式;与 `--auth-choice token` 一起使用) - `--token-profile-id `(非交互式;默认:`:manual`) - `--token-expires-in `(非交互式;例如 `365d`、`12h`) +- `--secret-input-mode `(默认 `plaintext`;使用 `ref` 可存储提供商默认环境引用,而非明文密钥) - `--anthropic-api-key ` - `--openai-api-key ` +- `--mistral-api-key ` - `--openrouter-api-key ` - `--ai-gateway-api-key ` - `--moonshot-api-key ` @@ -325,10 +361,17 @@ openclaw [--dev] [--profile ] - `--zai-api-key ` - `--minimax-api-key ` - `--opencode-zen-api-key ` +- `--opencode-go-api-key ` +- `--custom-base-url `(非交互式;与 `--auth-choice custom-api-key` 或 `--auth-choice ollama` 一起使用) +- `--custom-model-id `(非交互式;与 `--auth-choice custom-api-key` 或 `--auth-choice ollama` 一起使用) +- `--custom-api-key `(非交互式;可选;与 `--auth-choice custom-api-key` 一起使用;省略时回退到 `CUSTOM_API_KEY`) +- `--custom-provider-id `(非交互式;可选自定义提供商 id) +- `--custom-compatibility `(非交互式;可选;默认 `openai`) - `--gateway-port ` - `--gateway-bind ` - `--gateway-auth ` - `--gateway-token ` +- `--gateway-token-ref-env `(非交互式;将 `gateway.auth.token` 存储为环境 SecretRef;要求该环境变量已设置;不能与 `--gateway-token` 一起使用) - `--gateway-password ` - `--remote-url ` - `--remote-token ` @@ -341,35 +384,39 @@ openclaw [--dev] [--profile ] - `--skip-skills` - `--skip-health` - `--skip-ui` -- `--node-manager `(推荐 pnpm;不建议将 bun 用于 Gateway 网关运行时) +- `--node-manager `(推荐 pnpm;不推荐将 bun 用作 Gateway 网关运行时) - `--json` ### `configure` -交互式配置向导(模型、渠道、Skills、Gateway 网关)。 +交互式配置向导(模型、渠道、Skills、gateway)。 ### `config` -非交互式配置辅助工具(get/set/unset)。不带子命令运行 `openclaw config` 会启动向导。 +非交互式配置助手(get/set/unset/file/validate)。直接运行 `openclaw config` 而不带 +子命令会启动向导。 子命令: -- `config get `:打印配置值(点/括号路径)。 -- `config set `:设置值(JSON5 或原始字符串)。 -- `config unset `:删除值。 +- `config get `:打印一个配置值(点 / 方括号路径)。 +- `config set `:设置一个值(JSON5 或原始字符串)。 +- `config unset `:移除一个值。 +- `config file`:打印当前活动配置文件路径。 +- `config validate`:根据 schema 验证当前配置,而不启动 gateway。 +- `config validate --json`:输出机器可读的 JSON。 ### `doctor` -健康检查 + 快速修复(配置 + Gateway 网关 + 旧版服务)。 +健康检查 + 快速修复(配置 + gateway + 旧版服务)。 选项: -- `--no-workspace-suggestions`:禁用工作区记忆提示。 -- `--yes`:无提示接受默认值(无头模式)。 +- `--no-workspace-suggestions`:禁用工作区内存提示。 +- `--yes`:接受默认值而不提示(无头)。 - `--non-interactive`:跳过提示;仅应用安全迁移。 -- `--deep`:扫描系统服务以查找额外的 Gateway 网关安装。 +- `--deep`:扫描系统服务以查找额外的 gateway 安装。 -## 渠道辅助工具 +## 渠道助手 ### `channels` @@ -378,19 +425,21 @@ openclaw [--dev] [--profile ] 子命令: - `channels list`:显示已配置的渠道和认证配置文件。 -- `channels status`:检查 Gateway 网关可达性和渠道健康状况(`--probe` 运行额外检查;使用 `openclaw health` 或 `openclaw status --deep` 进行 Gateway 网关健康探测)。 -- 提示:`channels status` 在检测到常见配置错误时会打印带有建议修复的警告(然后指向 `openclaw doctor`)。 -- `channels logs`:显示 Gateway 网关日志文件中最近的渠道日志。 -- `channels add`:不传标志时使用向导式设置;标志切换到非交互模式。 -- `channels remove`:默认禁用;传 `--delete` 可无提示删除配置条目。 -- `channels login`:交互式渠道登录(仅限 WhatsApp Web)。 -- `channels logout`:登出渠道会话(如支持)。 +- `channels status`:检查 gateway 可达性和渠道健康状态(`--probe` 会运行额外检查;gateway 健康探测请使用 `openclaw health` 或 `openclaw status --deep`)。 +- 提示:如果能够检测到常见配置错误,`channels status` 会打印带建议修复方式的警告(随后指向 `openclaw doctor`)。 +- `channels logs`:显示 gateway 日志文件中的最近渠道日志。 +- `channels add`:未传入任何标志时为向导式设置;传入标志后切换为非交互模式。 + - 当向仍使用单账户顶层配置的渠道添加非默认账户时,OpenClaw 会先将账户作用域值移动到 `channels..accounts.default`,再写入新账户。 + - 非交互式 `channels add` 不会自动创建 / 升级绑定;仅渠道绑定会继续匹配默认账户。 +- `channels remove`:默认执行禁用;传入 `--delete` 可在无提示下删除配置项。 +- `channels login`:交互式渠道登录(仅 WhatsApp Web)。 +- `channels logout`:登出某个渠道会话(如支持)。 通用选项: - `--channel `:`whatsapp|telegram|discord|googlechat|slack|mattermost|signal|imessage|msteams` - `--account `:渠道账户 id(默认 `default`) -- `--name